class Publisher < Project
- Inherits from
Project: #api, #prepare, #validate, #merged, #inspect_release, #inspect_commit, #doctor, #apply
Builds one artifact, preserves it before upload, and resumes without moving existing tags.
Definitions
RegistryPending = Registry::Pending
Compatibility name for a registered package whose download is pending.
def initialize(root, registry: Registry.new)
Load repository policy and the registry used to verify published artifacts.
Signature
-
parameter
rootString The repository root containing
config/release.yaml.-
parameter
registryRegistry The registry providing package digests and attestation verification.
Implementation
def initialize(root, registry: Registry.new)
super(root)
@registry = registry
end
def build(number)
Build or restore this workflow run's artifact, after validating the actual merged commit.
Signature
-
parameter
numberString | Integer The merged release PR number.
-
returns
Hash The release receipt. Extends
Bake::Gem::GitHub::Project#inspect_releasemetadata withfile,sha256,run_id, andsigning; writes the receipt topkg/release.jsonand Actions outputs when configured.-
raises
RuntimeError If the workflow identity, source, or retained artifact is invalid, or published bytes cannot be recovered.
Implementation
def build(number)
guard_environment
evidence = inspect_release(number) or raise "PR does not change the version."
raise "Checkout must match the merged commit." unless @release.resolve("HEAD") == evidence.fetch(:commit)
path = File.join(@root, "pkg")
FileUtils.mkdir_p(path)
artifact = "release-#{evidence.fetch(:commit)}"
run = ENV.fetch("GITHUB_RUN_ID")
artifacts = api("actions/runs/#{run}/artifacts?per_page=100").fetch("artifacts")
retained = artifacts.find{|entry| entry.fetch("name") == artifact}
if retained && !retained.fetch("expired")
system("gh", "run", "download", run, "--repo", @repository, "--name", artifact, "--dir", path, chdir: @root)
elsif release = github_release("v#{evidence.fetch(:version)}")
restore_release(release, evidence, path)
elsif retained
raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying."
else
receipt = build_receipt(evidence, path, run)
return output(receipt, restored: false)
end
receipt = restored_receipt(evidence)
return output(receipt, restored: true)
end
def publish(number)
Verify both attestations, upload exactly those bytes, then create only the intended tag and release.
Signature
-
parameter
numberString | Integer The merged release PR number.
-
returns
Hash The verified receipt after registry verification and GitHub finalization.
-
raises
RuntimeError If source, signatures, registry content, tags, or release assets conflict, or propagation times out.
-
raises
Bake::Gem::CommandExecutionError If a verification or publishing command fails; rerunning resumes from retained artifacts.
Implementation
def publish(number)
guard_environment
receipt = load_receipt
verify_source(receipt, number)
package = File.join(@root, "pkg", receipt.fetch(:file))
bundle = "#{package}.sigstore.json"
verify_artifact(package, bundle)
tag = "v#{receipt.fetch(:version)}"
guard_tag(tag, receipt.fetch(:commit))
remote_digest = @registry.digest(receipt.fetch(:name), receipt.fetch(:version))
if remote_digest
raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256)
end
# Preserve verified bytes independently of workflow attempts before uploading:
release = preserve_release(receipt)
unless remote_digest
gem_command("push", package, "--host", "https://rubygems.org", "--attestation", bundle)
end
@registry.verify(receipt, bundle)
finalize_release(release, tag, receipt.fetch(:commit))
return receipt
end
def load_receipt
Load artifact evidence and verify the stored digest and filename.
Signature
-
returns
Hash The receipt with symbol keys, including the verified
fileandsha256.-
raises
RuntimeError If the package filename is invalid or its bytes do not match the receipt.
Implementation
def load_receipt
receipt = JSON.parse(File.read(File.join(@root, "pkg", "release.json")), symbolize_names: true)
filename = receipt.fetch(:file)
raise "Invalid artifact filename." unless filename == File.basename(filename) && filename.end_with?(".gem")
raise "Artifact digest mismatch." unless Digest::SHA256.file(File.join(@root, "pkg", filename)).hexdigest == receipt.fetch(:sha256)
return receipt
end
def guard_tag(tag, commit)
Refuse local or remote tag collisions before uploading a package.
Signature
-
parameter
tagString The version tag to publish.
-
parameter
commitString The intended release commit.
-
returns
Nil If local and remote tags are absent or already identify the intended commit.
-
raises
RuntimeError If an existing tag identifies another commit.
Implementation
def guard_tag(tag, commit)
local = readlines("git", "tag", "--list", tag, chdir: @root)
raise "Release tag points to another commit." if local.any? && @release.resolve(tag) != commit
remote = readlines("git", "ls-remote", "--tags", "origin", "refs/tags/#{tag}", "refs/tags/#{tag}^{}", chdir: @root).map{|line| line.split}
peeled = remote.find{|sha, ref| ref.end_with?("^{}")} || remote.first
raise "Remote release tag points to another commit." if peeled && peeled.first != commit
end
def restore_release(release, evidence, path)
Restore original files from a complete archive or a legacy set of assets.
Implementation
def restore_release(release, evidence, path)
guard_release(release, evidence.fetch(:commit))
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
files = release_files(file: filename)
if backup = release.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}
contents = read_backup(release, backup, files)
# Check every local file before writing any restored content:
contents.each do |name, content|
file = File.join(path, name)
raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content
end
return contents.each do |name, content|
File.binwrite(File.join(path, name), content)
end
else
names = release.fetch("assets").map{|asset| asset.fetch("name")}
unless files.all?{|file| names.include?(File.basename(file))}
raise "Retained release is incomplete; restore the original files before retrying."
end
return system(
"gh", "release", "download", release.fetch("tag_name"), "--repo", @repository,
"--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root,
)
end
end
def build_receipt(evidence, path, run)
Build only an unpublished version and retain the source identity and package digest.
Implementation
def build_receipt(evidence, path, run)
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
if @registry.digest(evidence.fetch(:name), evidence.fetch(:version))
raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
end
package = build_package(path)
raise "Unexpected package filename." unless File.basename(package) == filename
receipt = evidence.merge(
file: filename,
sha256: Digest::SHA256.file(package).hexdigest,
run_id: run,
signing: @config.fetch("signing"),
)
File.write(File.join(path, "release.json"), JSON.pretty_generate(receipt) + "\n")
return receipt
end
def restored_receipt(evidence)
Compare recovered evidence with the independently validated release source.
Implementation
def restored_receipt(evidence)
receipt = load_receipt
[:name, :version, :commit, :repository, :pull_request].each do |key|
raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key]
end
return receipt
end
def verify_source(receipt, number)
Bind the receipt to the actual merged PR and regenerated release content.
Implementation
def verify_source(receipt, number)
pull_request = merged(number)
unless receipt[:commit] == pull_request.fetch("merge_commit_sha") &&
receipt[:pull_request] == pull_request.fetch("number") &&
receipt[:repository] == @repository
raise "Artifact is not for this merged PR."
end
raise "Checkout must match the artifact source." unless @release.resolve("HEAD") == receipt[:commit]
metadata = @release.validate(base: "#{receipt[:commit]}^1", candidate: receipt[:commit])
return [:name, :version, :commit].each do |key|
raise "Artifact #{key} differs from the merged source." unless receipt[key] == metadata[key]
end
end
def verify_artifact(package, bundle)
Verify the optional certificate signature and both attestation formats.
Implementation
def verify_artifact(package, bundle)
verify_certificate(package) if @config.fetch("signing")
identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@refs/heads/#{@config.fetch('branch')}"
gem_command(
"exec", "sigstore-cli:0.2.3", "verify", package, "--bundle", bundle,
"--certificate-identity", identity,
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
)
return verify_provenance(package)
end
def finalize_release(release, tag, commit)
Publish the version tag and draft only after registry verification completes.
Implementation
def finalize_release(release, tag, commit)
unless readlines("git", "tag", "--list", tag, chdir: @root).any?
system("git", "tag", tag, commit, chdir: @root)
end
push("refs/tags/#{tag}")
if release.fetch("draft")
return system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root)
end
end
def create_draft(receipt, tag)
Create a draft using notes from the exact release checkout.
Implementation
def create_draft(receipt, tag)
notes = Bake::Releases.notes(tag, path: File.join(@root, "releases.md"))
metadata = "#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n"
return Tempfile.create("release") do |file|
file.write(JSON.generate(
tag_name: tag,
draft: true,
target_commitish: receipt.fetch(:commit),
name: tag,
body: [notes, metadata].compact.join("\n"),
))
file.flush
# Use the creation response because the release list can remain stale:
JSON.parse(readlines(
"gh", "api", "repos/#{@repository}/releases", "--method", "POST",
"--input", file.path, chdir: @root,
).join)
end
end
def verify_assets(assets, files)
Refuse to overwrite individual assets containing different bytes.
Implementation
def verify_assets(assets, files)
files.each do |file|
if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
raise "Existing release asset differs: #{file}"
end
end
end
end
def preserve_backup(release, files)
Preserve the complete set before uploading individual assets.
Implementation
def preserve_backup(release, files)
if asset = release.fetch("assets").find{|entry| entry.fetch("name") == "release.tar"}
contents = read_backup(release, asset, files)
unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)}
raise "Existing release backup differs."
end
else
backup_path = File.join(@root, "pkg/release.tar")
Backup.write(backup_path, files)
return system("gh", "release", "upload", release.fetch("tag_name"), backup_path, "--repo", @repository, chdir: @root)
end
end