From de3f0adc8dab5094e5b17a79d0f75ff7305da78f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:33:25 +1200 Subject: [PATCH 1/9] Improve layout and make multi-step return values explicit. --- bake/gem/github.rb | 16 ++- bake/gem/github/release.rb | 7 +- bake/gem/github/setup.rb | 6 +- lib/bake/gem/github/backup.rb | 4 +- lib/bake/gem/github/project.rb | 53 ++++++-- lib/bake/gem/github/publisher.rb | 117 ++++++++++++++---- lib/bake/gem/github/setup.rb | 80 ++++++++++-- test/bake/gem/github/backup.rb | 4 + test/bake/gem/github/project.rb | 14 +++ test/bake/gem/github/project/prepare.rb | 7 ++ test/bake/gem/github/publisher.rb | 5 + test/bake/gem/github/publisher/environment.rb | 5 + test/bake/gem/github/publisher/provenance.rb | 1 + test/bake/gem/github/publisher/recovery.rb | 34 +++++ test/bake/gem/github/publisher/registry.rb | 14 +++ test/bake/gem/github/release.rb | 5 + test/bake/gem/github/setup.rb | 12 ++ test/bake/gem/github/setup/update.rb | 4 + 18 files changed, 339 insertions(+), 49 deletions(-) diff --git a/bake/gem/github.rb b/bake/gem/github.rb index 6865241..6907dfe 100644 --- a/bake/gem/github.rb +++ b/bake/gem/github.rb @@ -13,19 +13,29 @@ def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4") require_relative "../../lib/bake/gem/github/setup" require "bake/gem/shell" + helper = Object.new.extend(Bake::Gem::Shell) remote = if repository && branch {} else JSON.parse(helper.readlines("gh", "repo", "view", "--json", "nameWithOwner,defaultBranchRef", chdir: context.root).join) end - options = {repository: repository || remote.fetch("nameWithOwner"), branch: branch || remote.fetch("defaultBranchRef").fetch("name"), checks: checks, approvals: approvals, ruby: ruby} + + options = { + repository: repository || remote.fetch("nameWithOwner"), + branch: branch || remote.fetch("defaultBranchRef").fetch("name"), + checks: checks, + approvals: approvals, + ruby: ruby, + } options[:signing] = signing unless signing.nil? - Bake::Gem::GitHub::Setup.new(context.root).generate(**options) + + return Bake::Gem::GitHub::Setup.new(context.root).generate(**options) end # Show the desired rules, existing rules, environments, and RubyGems bootstrap values. def doctor require_relative "../../lib/bake/gem/github/project" - Bake::Gem::GitHub::Project.new(context.root).doctor + + return Bake::Gem::GitHub::Project.new(context.root).doctor end diff --git a/bake/gem/github/release.rb b/bake/gem/github/release.rb index 6bbbeee..4db895c 100644 --- a/bake/gem/github/release.rb +++ b/bake/gem/github/release.rb @@ -26,13 +26,15 @@ def major(refresh: false) # Resolve and validate a merged PR, emitting a commit output for the publishing job. def resolve(number: ENV.fetch("RELEASE_PR")) result = Bake::Gem::GitHub::Project.new(context.root).inspect_release(number) + if path = ENV["GITHUB_OUTPUT"] File.open(path, "a") do |file| file.puts "release=#{!result.nil?}" file.puts "commit=#{result.fetch(:commit)}" if result end end - result + + return result end # Build or restore the exact artifact for a merged release PR. @@ -50,5 +52,6 @@ def resume(run:) project = Bake::Gem::GitHub::Project.new(context.root) details = project.api("actions/runs/#{Integer(run)}") raise "Expected a release-publish workflow run." unless details.fetch("path") == ".github/workflows/release-publish.yaml" - project.system("gh", "run", "rerun", run.to_s, "--repo", project.config.fetch("repository"), chdir: context.root) + + return project.system("gh", "run", "rerun", run.to_s, "--repo", project.config.fetch("repository"), chdir: context.root) end diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index 689bd58..f0da36c 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -11,11 +11,13 @@ def plan # Apply the four managed rulesets using the current gh administrator credentials. def apply require_relative "../../../lib/bake/gem/github/project" - Bake::Gem::GitHub::Project.new(context.root).apply + + return Bake::Gem::GitHub::Project.new(context.root).apply end # Update generated files in the working tree using config/release.yaml and the installed templates. def update require_relative "../../../lib/bake/gem/github/setup" - Bake::Gem::GitHub::Setup.new(context.root).update + + return Bake::Gem::GitHub::Setup.new(context.root).update end diff --git a/lib/bake/gem/github/backup.rb b/lib/bake/gem/github/backup.rb index fd15871..2db76e5 100644 --- a/lib/bake/gem/github/backup.rb +++ b/lib/bake/gem/github/backup.rb @@ -24,6 +24,7 @@ def self.write(path, files) # Read only the expected regular files; reject missing, duplicate, or unexpected entries before extraction. def self.read(path, names) files = {} + File.open(path, "rb") do |input| ::Gem::Package::TarReader.new(input) do |archive| archive.each do |entry| @@ -34,7 +35,8 @@ def self.read(path, names) end end raise "Release backup is incomplete." unless files.keys.sort == names.sort - files + + return files end end end diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index cd1d598..1932b0c 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -37,17 +37,24 @@ def prepare(context, bump, refresh: false) Release::BUMPS.fetch(bump) helper = Helper.new(@root) helper.guard_clean + branch = @config.fetch("branch") raise "Prepare releases from #{branch}." unless helper.current_branch == branch system("git", "fetch", "origin", branch, "--tags", chdir: @root) raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}") - pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url,isCrossRepository", "--limit", "1000", chdir: @root).join) + + pulls = JSON.parse(readlines( + "gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", + "--json", "headRefName,url,isCrossRepository", "--limit", "1000", chdir: @root, + ).join) pulls = pulls.select{|pr| !pr["isCrossRepository"] && pr.fetch("headRefName").start_with?("releases/v")} raise "Multiple release PRs are open; select one before preparing another release." if pulls.size > 1 + existing = pulls.first version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join name = "releases/v#{version}" raise "Existing release PR uses #{existing.fetch('headRefName')}; use its bump type or close it first." if existing && existing.fetch("headRefName") != name + base = @release.resolve("HEAD") ref = "refs/heads/#{name}" remote = readlines("git", "ls-remote", "--heads", "origin", ref, chdir: @root).first @@ -55,10 +62,12 @@ def prepare(context, bump, refresh: false) system("git", "fetch", "origin", ref, chdir: @root) remote = @release.resolve("FETCH_HEAD") end + candidate = remote if !candidate && readlines("git", "branch", "--list", name, chdir: @root).any? candidate = @release.resolve(ref) end + if candidate && refresh # Preserve the complete previous tree before replacing the release branch: backup = "refs/heads/release-backups/v#{version}/#{candidate}" @@ -71,15 +80,28 @@ def prepare(context, bump, refresh: false) context.lookup("gem:release:branch:#{bump}").call candidate = @release.resolve("HEAD") end + metadata = @release.validate(base: base, candidate: candidate) raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version + push("--force-with-lease=#{ref}:#{remote}", "#{candidate}:#{ref}") return existing.fetch("url") if existing - body = "Release #{helper.gemspec.name} #{version}.\n\nPrepared from #{base}. The complete release tree is regenerated during validation. Merging publishes the resulting commit through release-publish.yaml after native reviews and required CI (or explicit administrator bypass).\n" - Tempfile.create("release-pr") do |file| + + body = <<~BODY + Release #{helper.gemspec.name} #{version}. + + Prepared from #{base}. The complete release tree is regenerated during validation. \ + Merging publishes the resulting commit through release-publish.yaml after native \ + reviews and required CI (or explicit administrator bypass). + BODY + + return Tempfile.create("release-pr") do |file| file.write(body) file.flush - readlines("gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", name, "--title", "Release v#{version}", "--body-file", file.path, chdir: @root).join.strip + readlines( + "gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", name, + "--title", "Release v#{version}", "--body-file", file.path, chdir: @root, + ).join.strip end end @@ -88,20 +110,29 @@ def merged(number) raise "Expected a PR number." unless number.to_s.match?(/\A[1-9]\d*\z/) pr = api("pulls/#{number}") raise "PR must be merged into the configured branch." unless pr["merged"] && pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository + commit = pr.fetch("merge_commit_sha") raise "Invalid merged commit." unless commit.match?(/\A[0-9a-f]{40,64}\z/) + system("git", "fetch", "origin", @config.fetch("branch"), "--tags", chdir: @root) system("git", "merge-base", "--is-ancestor", commit, "origin/#{@config.fetch('branch')}", chdir: @root) - pr + + return pr end # Resolve release identity; ordinary merged PRs do not publish. def inspect_release(number) pr = merged(number) + commit = pr.fetch("merge_commit_sha") metadata = @release.validate(base: "#{commit}^1", candidate: commit, optional: true) if metadata - metadata.merge(repository: @repository, pull_request: pr.fetch("number"), merged_by: pr.dig("merged_by", "login"), pull_request_url: pr.fetch("html_url")) + return metadata.merge( + repository: @repository, + pull_request: pr.fetch("number"), + merged_by: pr.dig("merged_by", "login"), + pull_request_url: pr.fetch("html_url"), + ) end end @@ -111,19 +142,25 @@ def doctor desired_rules: Setup.rules(@config), existing_rules: api("rulesets?per_page=100"), environments: api("environments"), - trusted_publisher: {repository_owner: @repository.split("/").first, repository_name: @repository.split("/").last, workflow_filename: "release-publish.yaml", environment: @config.fetch("environment")} + trusted_publisher: { + repository_owner: @repository.split("/").first, + repository_name: @repository.split("/").last, + workflow_filename: "release-publish.yaml", + environment: @config.fetch("environment"), + } } end # Apply only the named rulesets generated by setup. Invoke after reviewing doctor output. def apply existing = api("rulesets?per_page=100") - Setup.rules(@config).each_value do |rule| + return Setup.rules(@config).each_value do |rule| matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)} raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1 current = matches.first path = "repos/#{@repository}/rulesets" path += "/#{current.fetch('id')}" if current + Tempfile.create("release-rule") do |file| file.write(JSON.generate(rule)) file.flush diff --git a/lib/bake/gem/github/publisher.rb b/lib/bake/gem/github/publisher.rb index d4a109e..0b8069f 100644 --- a/lib/bake/gem/github/publisher.rb +++ b/lib/bake/gem/github/publisher.rb @@ -24,12 +24,15 @@ 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)}") @@ -38,17 +41,23 @@ def build(number) files = release_files(file: filename) if backup = release.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"} contents = read_backup(release, backup, files) + contents.each do |name, content| file = File.join(path, name) raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content end + contents.each do |name, content| File.binwrite(File.join(path, name), content) end else names = release.fetch("assets").map{|asset| asset.fetch("name")} raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))} - system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root) + system( + "gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, + "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, + chdir: @root, + ) end elsif retained raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying." @@ -57,22 +66,32 @@ def build(number) 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")) + + 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 output(receipt, restored: false) end + receipt = load_receipt [:name, :version, :commit, :repository, :pull_request].each do |key| raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key] end - output(receipt, restored: true) + + return output(receipt, restored: true) end # Verify both attestations, upload exactly those bytes, then create only the intended tag and release. def publish(number) guard_environment + receipt = load_receipt pr = merged(number) raise "Artifact is not for this merged PR." unless receipt[:commit] == pr.fetch("merge_commit_sha") && receipt[:pull_request] == pr.fetch("number") && receipt[:repository] == @repository @@ -81,32 +100,45 @@ def publish(number) [:name, :version, :commit].each do |key| raise "Artifact #{key} differs from the merged source." unless receipt[key] == metadata[key] end + package = File.join(@root, "pkg", receipt.fetch(:file)) verify_certificate(package) if @config.fetch("signing") + bundle = "#{package}.sigstore.json" 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") + gem_command( + "exec", "sigstore-cli:0.2.3", "verify", package, "--bundle", bundle, + "--certificate-identity", identity, "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + ) verify_provenance(package) + 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 + # Keep 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 + verify_registry(receipt, bundle) unless readlines("git", "tag", "--list", tag, chdir: @root).any? system("git", "tag", tag, receipt.fetch(:commit), chdir: @root) end - system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", "refs/tags/#{tag}", chdir: @root) + system( + "git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", + "push", "origin", "refs/tags/#{tag}", chdir: @root, + ) if release.fetch("draft") system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root) end - receipt + + return receipt end # Load artifact evidence and verify the stored digest and filename. @@ -115,13 +147,15 @@ def load_receipt 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) - receipt + + return receipt end # Refuse local or remote tag collisions before uploading a package. 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 @@ -133,15 +167,20 @@ def github_release(tag) pages = JSON.parse(readlines("gh", "api", "--paginate", "--slurp", "repos/#{@repository}/releases?per_page=100", chdir: @root).join) matches = pages.flatten(1).select{|release| release.fetch("tag_name") == tag} raise "Multiple GitHub releases have the same tag." if matches.size > 1 + if release = matches.first return api("releases/#{release.fetch('id')}") end + # Resolve pending tags directly when the REST list has not caught up: owner, name = @repository.split("/", 2) query = "query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { databaseId } } }" - result = JSON.parse(readlines("gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f", "name=#{name}", "-f", "tag=#{tag}", chdir: @root).join) + result = JSON.parse(readlines( + "gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f", + "name=#{name}", "-f", "tag=#{tag}", chdir: @root, + ).join) if release = result.fetch("data").fetch("repository").fetch("release") - api("releases/#{release.fetch('databaseId')}") + return api("releases/#{release.fetch('databaseId')}") end end @@ -163,22 +202,32 @@ def preserve_release(receipt) metadata = "#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n" Tempfile.create("release") do |file| file.write(JSON.generate( - tag_name: tag, draft: true, target_commitish: receipt.fetch(:commit), name: tag, + tag_name: tag, + draft: true, + target_commitish: receipt.fetch(:commit), + name: tag, body: [notes, metadata].compact.join("\n") )) file.flush # The release list can remain stale after a successful creation: - release = JSON.parse(readlines("gh", "api", "repos/#{@repository}/releases", "--method", "POST", "--input", file.path, chdir: @root).join) + release = JSON.parse(readlines( + "gh", "api", "repos/#{@repository}/releases", "--method", "POST", "--input", + file.path, chdir: @root, + ).join) end end + guard_release(release, receipt.fetch(:commit)) + assets = release.fetch("assets") files = release_files(receipt) + files.each do |file| if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)} raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}" end end + if backup = assets.find{|asset| asset.fetch("name") == "release.tar"} contents = read_backup(release, backup, files) raise "Existing release backup differs." unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)} @@ -188,17 +237,22 @@ def preserve_release(receipt) Backup.write(backup, files) system("gh", "release", "upload", tag, backup, "--repo", @repository, chdir: @root) end + files.each do |file| unless assets.any?{|asset| asset.fetch("name") == File.basename(file)} system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root) end end - release + + return release end def read_backup(release, asset, files) Tempfile.create("release-backup") do |file| - system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--pattern", "release.tar", "--output", file.path, "--clobber", chdir: @root) + system( + "gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, + "--pattern", "release.tar", "--output", file.path, "--clobber", chdir: @root, + ) raise "Release backup digest mismatch." unless asset.fetch("digest") == "sha256:#{Digest::SHA256.file(file.path).hexdigest}" Backup.read(file.path, files.map{|path| File.basename(path)}) end @@ -218,6 +272,7 @@ def verify_registry(receipt, bundle, attempts: 7, delay: 10) rescue RegistryPending # The version API can become visible before the gem download: end + if attempt < attempts - 1 Console.info(self, "Waiting for RubyGems to serve the release.") sleep(delay) @@ -242,9 +297,12 @@ def guard_environment def contains_bundle?(value, bundle) return true if value == bundle case value - when Hash then value.values.any?{|child| contains_bundle?(child, bundle)} - when Array then value.any?{|child| contains_bundle?(child, bundle)} - else false + when Hash + return value.values.any?{|child| contains_bundle?(child, bundle)} + when Array + return value.any?{|child| contains_bundle?(child, bundle)} + else + return false end end @@ -252,6 +310,7 @@ def verify_certificate(path) policy = ::Gem::Security::Policy.new("Release", only_trusted: false) package = ::Gem::Package.new(path, policy) package.verify + signer = OpenSSL::X509::Certificate.new(package.spec.cert_chain.last) expected = OpenSSL::X509::Certificate.new(File.read(File.join(@root, "release.cert"))) raise "Package signer differs from release.cert." unless signer.to_der == expected.to_der @@ -261,11 +320,13 @@ def build_package(path) unless @config.fetch("signing") return @release.worktree("HEAD"){|source| @release.bake(source, "gem:build", root: path, signing_key: false)} end + certificate = OpenSSL::X509::Certificate.new(File.read(File.join(@root, "release.cert"))) key = OpenSSL::PKey.read(ENV.fetch("GEM_SIGNING_KEY")) raise "Signing key does not match release.cert." unless certificate.check_private_key(key) raise "Signing certificate is not currently valid." unless (certificate.not_before..certificate.not_after).cover?(Time.now) - Tempfile.create("gem-signing-key") do |file| + + return Tempfile.create("gem-signing-key") do |file| file.chmod(0600) file.write(ENV.fetch("GEM_SIGNING_KEY")) file.flush @@ -283,37 +344,47 @@ def output(receipt, restored:) file.puts "restored=#{restored}" end end - receipt + + return receipt end def verify_provenance(package) ref = "refs/heads/#{@config.fetch('branch')}" identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@#{ref}" - # The signed receipt binds the package digest to the release commit, independently of the workflow revision. + # The signed receipt binds the package digest to the release commit, independently of the workflow revision: [package, File.join(@root, "pkg", "release.json")].each do |file| - system("gh", "attestation", "verify", file, "--repo", @repository, "--bundle", File.join(@root, "pkg", "provenance.sigstore.json"), "--cert-identity", identity, "--source-ref", ref, "--deny-self-hosted-runners", chdir: @root) + system( + "gh", "attestation", "verify", file, "--repo", @repository, "--bundle", + File.join(@root, "pkg", "provenance.sigstore.json"), "--cert-identity", identity, + "--source-ref", ref, "--deny-self-hosted-runners", chdir: @root, + ) end end def registry_digest(name, version) - # Missing downloads can return 403; use the version API to establish absence. + # Missing downloads can return 403; use the version API to establish absence: return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby") + body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem") raise RegistryPending, "Published gem download is missing; retry after registry propagation." unless body - Digest::SHA256.hexdigest(body) + + return Digest::SHA256.hexdigest(body) end def registry_get(url, redirects: 5) uri = URI(url) raise "Registry redirect requires HTTPS." unless uri.scheme == "https" + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60){|http| http.get(uri.request_uri)} return nil if response.is_a?(Net::HTTPNotFound) + if response.is_a?(Net::HTTPRedirection) raise "Too many registry redirects." unless redirects > 0 return registry_get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1) end raise "Registry request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess) - response.body + + return response.body end end end diff --git a/lib/bake/gem/github/setup.rb b/lib/bake/gem/github/setup.rb index 9bdbe10..313281a 100644 --- a/lib/bake/gem/github/setup.rb +++ b/lib/bake/gem/github/setup.rb @@ -24,19 +24,37 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f raise "Unsupported branch name." unless branch.match?(/\A[\w.\/-]+\z/) raise "Select the required CI check names." if checks.empty? raise "Review count must be between 1 and 6." unless (1..6).include?(approvals) - config = {"schema" => 1, "repository" => repository, "branch" => branch, "checks" => (checks + ["Release validation"]).uniq, "approvals" => approvals, "signing" => signing, "ruby" => ruby, "environment" => "rubygems"} + + config = { + "schema" => 1, + "repository" => repository, + "branch" => branch, + "checks" => (checks + ["Release validation"]).uniq, + "approvals" => approvals, + "signing" => signing, + "ruby" => ruby, + "environment" => "rubygems", + } + files = render(config) - conflicts = files.keys.select{|name| File.exist?(File.join(@root, name)) && File.read(File.join(@root, name)) != files[name]} + conflicts = files.keys.select do |name| + path = File.join(@root, name) + File.exist?(path) && File.read(path) != files[name] + end + raise "Existing files differ; review them before regenerating: #{conflicts.join(', ')}" unless conflicts.empty? + write(files) - files.keys + + return files.keys end # Update generated files in the working tree using the existing configuration; return changed paths. def update config = YAML.safe_load_file(File.join(@root, "config/release.yaml")) raise "Unsupported release configuration." unless config.fetch("schema") == 1 - write(render(config)) + + return write(render(config)) end # Native review/check rules allow PR-only administrator bypass; history rules have no bypass. @@ -44,11 +62,48 @@ def self.rules(config) conditions = {ref_name: {include: ["refs/heads/#{config.fetch('branch')}"], exclude: []}} common = {target: "branch", enforcement: "active", conditions: conditions} bypass = [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "pull_request"}] - { - "reviews" => common.merge(name: "Gem release reviews", bypass_actors: bypass, rules: [{type: "pull_request", parameters: {required_approving_review_count: config.fetch("approvals"), dismiss_stale_reviews_on_push: true, require_last_push_approval: true, required_review_thread_resolution: true, require_code_owner_review: false, allowed_merge_methods: ["merge", "squash"]}}]), - "checks" => common.merge(name: "Gem release checks", bypass_actors: bypass, rules: [{type: "required_status_checks", parameters: {strict_required_status_checks_policy: true, do_not_enforce_on_create: false, required_status_checks: config.fetch("checks").map{|name| {context: name}}}}]), - "history" => common.merge(name: "Gem release history", bypass_actors: [], rules: [{type: "deletion"}, {type: "non_fast_forward"}]), - "tags" => {name: "Gem release tags", target: "tag", enforcement: "active", bypass_actors: [], conditions: {ref_name: {include: ["refs/tags/v*"], exclude: []}}, rules: [{type: "deletion"}, {type: "non_fast_forward"}]} + + return { + "reviews" => common.merge( + name: "Gem release reviews", + bypass_actors: bypass, + rules: [{ + type: "pull_request", + parameters: { + required_approving_review_count: config.fetch("approvals"), + dismiss_stale_reviews_on_push: true, + require_last_push_approval: true, + required_review_thread_resolution: true, + require_code_owner_review: false, + allowed_merge_methods: ["merge", "squash"], + }, + }], + ), + "checks" => common.merge( + name: "Gem release checks", + bypass_actors: bypass, + rules: [{ + type: "required_status_checks", + parameters: { + strict_required_status_checks_policy: true, + do_not_enforce_on_create: false, + required_status_checks: config.fetch("checks").map{|name| {context: name}}, + }, + }], + ), + "history" => common.merge( + name: "Gem release history", + bypass_actors: [], + rules: [{type: "deletion"}, {type: "non_fast_forward"}], + ), + "tags" => { + name: "Gem release tags", + target: "tag", + enforcement: "active", + bypass_actors: [], + conditions: {ref_name: {include: ["refs/tags/v*"], exclude: []}}, + rules: [{type: "deletion"}, {type: "non_fast_forward"}], + }, } end @@ -58,8 +113,10 @@ def write(files) files.filter_map do |name, content| path = File.join(@root, name) next if File.exist?(path) && File.read(path) == content + FileUtils.mkdir_p(File.dirname(path)) File.write(path, content) + name end end @@ -68,15 +125,18 @@ def render(config) branch = config.fetch("branch") ruby = config.fetch("ruby") signing = config.fetch("signing") + templates = File.expand_path("../../../../templates", __dir__) files = {"config/release.yaml" => YAML.dump(config)} Dir.glob("*.erb", base: templates).each do |name| files[".github/workflows/#{name.delete_suffix('.erb')}"] = ERB.new(File.read(File.join(templates, name)), trim_mode: "-").result(binding) end + self.class.rules(config).each do |name, rule| files[".github/release-rules/#{name}.json"] = JSON.pretty_generate(rule) + "\n" end - files + + return files end end end diff --git a/test/bake/gem/github/backup.rb b/test/bake/gem/github/backup.rb index 5e2a716..eee97dc 100644 --- a/test/bake/gem/github/backup.rb +++ b/test/bake/gem/github/backup.rb @@ -15,12 +15,14 @@ files = {"example.gem" => "\x00\xffpackage".b, "release.json" => "{}"} files.each{|name, content| File.binwrite(File.join(root, name), content)} subject.write(path, files.keys.map{|name| File.join(root, name)}) + expect(subject.read(path, files.keys)).to be == files end it "rejects an incomplete backup" do File.write(File.join(root, "example.gem"), "package") subject.write(path, [File.join(root, "example.gem")]) + expect{subject.read(path, ["example.gem", "release.json"])}.to raise_exception(RuntimeError, message: be =~ /incomplete/) end @@ -31,6 +33,7 @@ entries.each{|name| archive.add_file(name, 0644){|entry| entry.write("bytes")}} end end + expect{subject.read(path, ["example.gem"])}.to raise_exception(RuntimeError, message: be =~ /Unexpected release backup entry/) end end @@ -39,6 +42,7 @@ File.open(path, "wb") do |file| Gem::Package::TarWriter.new(file){|archive| archive.add_symlink("example.gem", "../outside", 0644)} end + expect{subject.read(path, ["example.gem"])}.to raise_exception(RuntimeError, message: be =~ /Unexpected release backup entry/) end end diff --git a/test/bake/gem/github/project.rb b/test/bake/gem/github/project.rb index 37f50e7..4fcb8a0 100644 --- a/test/bake/gem/github/project.rb +++ b/test/bake/gem/github/project.rb @@ -16,6 +16,7 @@ url = project.prepare(Bake::Context.load(Dir.pwd), "patch") {url: url, body: project.writes.first} RUBY + expect(result[:url]).to be == "https://github.com/socketry/example/pull/42" expect(result[:body]).to be(:include?, "Release example 1.0.1.") expect(git("branch", "--show-current")).to be == "releases/v1.0.1" @@ -32,6 +33,7 @@ "merge_commit_sha" => commit, "merged_by" => {"login" => "maintainer"}, "html_url" => result[:url] } evidence = project.inspect_release(42) + expect(evidence).to have_keys(name: be == "example", version: be == "1.0.1", commit: be == commit, merged_by: be == "maintainer") end @@ -44,6 +46,7 @@ project.pulls = [{"headRefName" => "releases/v1.0.1", "url" => "existing"}] project.prepare(Bake::Context.load(Dir.pwd), "patch") RUBY + expect(url).to be == "existing" expect(git("branch", "--show-current")).to be == "main" expect(git("rev-parse", "releases/v1.0.1")).to be == original @@ -51,6 +54,7 @@ it "refuses preparation from another branch" do git("checkout", "--quiet", "-b", "feature") + expect do isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")') end.to raise_exception(RuntimeError, message: be =~ /Prepare releases from main/) @@ -58,6 +62,7 @@ it "refuses preparation when the local default branch differs from origin" do git("commit", "--quiet", "--allow-empty", "-m", "Local change") + expect do isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")') end.to raise_exception(RuntimeError, message: be =~ /differs from origin/) @@ -75,6 +80,7 @@ it "accepts a merged commit in the remote default branch history" do git("commit", "--quiet", "--allow-empty", "-m", "Later development") git("push", "--quiet", "origin", "main") + expect(project.merged(42)).to be == pull end @@ -82,11 +88,13 @@ git("checkout", "--quiet", "-b", "unmerged") git("commit", "--quiet", "--allow-empty", "-m", "Unmerged change") pull["merge_commit_sha"] = git("rev-parse", "HEAD") + expect{project.merged(42)}.to raise_exception(Bake::Gem::CommandExecutionError) end it "rejects invalid commit identifiers" do pull["merge_commit_sha"] = "--help" + expect{project.merged(42)}.to raise_exception(RuntimeError, message: be =~ /Invalid merged commit/) end @@ -96,6 +104,7 @@ git("commit", "--quiet", "-m", "Documentation") git("push", "--quiet", "origin", "main") pull["merge_commit_sha"] = git("rev-parse", "HEAD") + expect(project.inspect_release(42)).to be_nil end end @@ -112,6 +121,7 @@ it "reports desired and observed settings without writing" do expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project) result = Bake::Context.load(repository).call("gem:github:setup:plan") + expect(result[:desired_rules].keys).to be == %w[reviews checks history tags] expect(result[:existing_rules]).to be == [] expect(result[:trusted_publisher][:workflow_filename]).to be == "release-publish.yaml" @@ -120,8 +130,10 @@ it "creates missing managed rulesets while preserving unrelated rulesets" do rules << {"id" => 123, "name" => "Unrelated policy"} + expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project) Bake::Context.load(repository).call("gem:github:setup:apply") + expect(project.writes.map{|rule| rule.fetch("name")}).to be == ["Gem release reviews", "Gem release checks", "Gem release history", "Gem release tags"] expect(project.requests.drop(1).all?{|request| request.include?("POST")}).to be == true end @@ -131,12 +143,14 @@ rules << {"id" => index + 1, "name" => rule.fetch(:name)} end project.apply + expect(project.requests.drop(1).map{|request| request[2]}).to be == (1..4).map{|id| "repos/socketry/example/rulesets/#{id}"} expect(project.requests.drop(1).all?{|request| request.include?("PUT")}).to be == true end it "refuses ambiguous managed rulesets" do rules.concat([{"id" => 1, "name" => "Gem release reviews"}, {"id" => 2, "name" => "Gem release reviews"}]) + expect{project.apply}.to raise_exception(RuntimeError, message: be =~ /Multiple rulesets/) expect(project.writes).to be == [] end diff --git a/test/bake/gem/github/project/prepare.rb b/test/bake/gem/github/project/prepare.rb index 6eb4dfa..5990b82 100644 --- a/test/bake/gem/github/project/prepare.rb +++ b/test/bake/gem/github/project/prepare.rb @@ -42,6 +42,7 @@ def advance_main end.to raise_exception(RuntimeError, message: be =~ /PR creation interrupted/) original = git("rev-parse", "HEAD") git("checkout", "--quiet", "main") + expect(prepare).to be == "https://github.com/socketry/example/pull/42" expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original end @@ -51,6 +52,7 @@ def advance_main original = git("rev-parse", "HEAD") git("checkout", "--quiet", "main") prepare + expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original end @@ -58,6 +60,7 @@ def advance_main prepare original = git("rev-parse", "HEAD") advance_main + expect{prepare(pulls: [pull])}.to raise_exception(RuntimeError, message: be =~ /Release content is stale/) expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original end @@ -70,8 +73,10 @@ def advance_main git("push", "--quiet", "origin", "releases/v1.0.1") original = git("rev-parse", "HEAD") advance_main + expect(prepare(refresh: true, pulls: [pull])).to be == "existing" remote = File.join(root, "remote") + expect(git("rev-parse", "release-backups/v1.0.1/#{original}", chdir: remote)).to be == original expect(git("show", "release-backups/v1.0.1/#{original}:manual.md", chdir: remote)).to be == "Keep this for review." expect(git("show", "releases/v1.0.1:releases.md", chdir: remote)).to be == "## v1.0.1\n\nAn additional change." @@ -83,6 +88,7 @@ def advance_main prepare original = git("rev-parse", "HEAD") advance_main + expect do isolated_project(<<~'RUBY') require "sus/mock" @@ -113,6 +119,7 @@ def advance_main isolated_project('Bake::Context.load(Dir.pwd).call("gem:release:branch:minor")') git("branch", "--move", "releases/v1.0.1") git("checkout", "--quiet", "main") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /requested version 1.0.1/) end diff --git a/test/bake/gem/github/publisher.rb b/test/bake/gem/github/publisher.rb index 5443cb1..7fc859b 100644 --- a/test/bake/gem/github/publisher.rb +++ b/test/bake/gem/github/publisher.rb @@ -36,14 +36,17 @@ def git(*arguments) File.write(File.join(root, "pkg", "example-1.0.1.gem"), "Original") receipt = {file: "example-1.0.1.gem", sha256: Digest::SHA256.hexdigest("Original")} File.write(File.join(root, "pkg", "release.json"), JSON.generate(receipt)) + expect(publisher.load_receipt).to be == receipt File.write(File.join(root, "pkg", "example-1.0.1.gem"), "Changed") + expect{publisher.load_receipt}.to raise_exception(RuntimeError, message: be =~ /digest mismatch/) end it "rejects an artifact path outside pkg" do FileUtils.mkdir_p(File.join(root, "pkg")) File.write(File.join(root, "pkg", "release.json"), JSON.generate(file: "../example.gem")) + expect{publisher.load_receipt}.to raise_exception(RuntimeError, message: be =~ /filename/) end @@ -51,6 +54,7 @@ def git(*arguments) commit git("tag", "v1.0.1") git("commit", "--allow-empty", "-m", "Later source") + expect{publisher.guard_tag("v1.0.1", git("rev-parse", "HEAD"))}.to raise_exception(RuntimeError, message: be =~ /another commit/) expect(git("rev-parse", "v1.0.1")).to be == commit end @@ -99,6 +103,7 @@ def git(*arguments) package.extract_files("extracted") {content: File.read("extracted/example.rb"), signer: OpenSSL::X509::Certificate.new(package.spec.cert_chain.last).to_der} RUBY + expect(result[:content]).to be == "ORIGINAL" expect(result[:signer]).to be == certificate.to_der end diff --git a/test/bake/gem/github/publisher/environment.rb b/test/bake/gem/github/publisher/environment.rb index 77a0580..7bd3dfe 100644 --- a/test/bake/gem/github/publisher/environment.rb +++ b/test/bake/gem/github/publisher/environment.rb @@ -17,6 +17,7 @@ package = Gem::Package.new(path) {version: package.spec.version.to_s, certificates: package.spec.cert_chain} RUBY + expect(result).to be == {version: "1.0.0", certificates: []} end @@ -26,6 +27,7 @@ Bake::Gem::GitHub::Publisher.new(Dir.pwd).send(:guard_environment) true RUBY + expect(result).to be == true end @@ -50,6 +52,7 @@ receipt = {file: "example-1.0.0.gem", commit: "a" * 40} Bake::Gem::GitHub::Publisher.new(Dir.pwd).send(:output, receipt, restored: true) RUBY + expect(File.readlines(output, chomp: true)).to be == ["package=pkg/example-1.0.0.gem", "artifact=release-#{'a' * 40}", "restored=true"] end @@ -64,6 +67,7 @@ def publisher.system(*arguments, **options) result = publisher.send(:gem_command, "push", "example.gem") result.merge(restored: ENV["BUNDLE_GEMFILE"] == original) RUBY + expect(result).to be == {arguments: ["gem", "push", "example.gem"], gemfile: nil, directory: File.realpath(repository), restored: true} end @@ -78,6 +82,7 @@ def publisher.system(*arguments, **options) end publisher.send(:gem_command, "push", "example.gem") RUBY + expect(result).to be == {arguments: ["gem", "push", "example.gem"], directory: File.realpath(repository)} end end diff --git a/test/bake/gem/github/publisher/provenance.rb b/test/bake/gem/github/publisher/provenance.rb index 0aa9e6d..e4539ec 100644 --- a/test/bake/gem/github/publisher/provenance.rb +++ b/test/bake/gem/github/publisher/provenance.rb @@ -30,6 +30,7 @@ "--cert-identity", "https://github.com/socketry/example/.github/workflows/release-publish.yaml@refs/heads/main", "--source-ref", "refs/heads/main", "--deny-self-hosted-runners" ] + expect(commands).to be == [ [["gh", "attestation", "verify", "/release/pkg/example.gem", *options], {chdir: "/release"}], [["gh", "attestation", "verify", "/release/pkg/release.json", *options], {chdir: "/release"}] diff --git a/test/bake/gem/github/publisher/recovery.rb b/test/bake/gem/github/publisher/recovery.rb index 9513db5..1f77e85 100644 --- a/test/bake/gem/github/publisher/recovery.rb +++ b/test/bake/gem/github/publisher/recovery.rb @@ -24,6 +24,7 @@ def before def restore FileUtils.rm_rf(File.join(@root, "pkg")) + expect(ENV).to receive(:fetch).with("GITHUB_RUN_ID").and_return("123") expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(@publisher) Bake::Context.load(root).call("gem:github:release:build", "number=42") @@ -85,6 +86,7 @@ def restore it "preserves the existing draft description when finalization is retried" do File.write(File.join(root, "releases.md"), "## v1.0.1\n\nOriginal notes.\n") @publisher.fail_release = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) body = @publisher.releases.first.fetch("body") + "\nMaintainer addition.\n" @publisher.releases.first["body"] = body @@ -100,35 +102,42 @@ def restore it "resumes finalization after upload without uploading or rebuilding again" do @publisher.fail_release = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) @publisher.fail_release = false restore + expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(@publisher) Bake::Context.load(root).call("gem:github:release:publish", "number=42") uploads = @publisher.commands.select{|args| args[0, 2] == ["gem", "push"]} + expect(uploads.size).to be == 1 expect(uploads.first).to be(:include?, "--attestation") expect(@publisher.commands.any?{|args| args.include?("push") && args.include?("--tags")}).to be == false expect(@publisher.releases.first.fetch("draft")).to be == false preserve = @publisher.commands.index{|args| args[0, 3] == ["gh", "release", "upload"]} upload = @publisher.commands.index{|args| args[0, 2] == ["gem", "push"]} + expect(preserve).to be < upload end it "does not upload to RubyGems if draft asset preservation fails" do @publisher.fail_preservation = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) expect(@publisher.remote_digest).to be_nil end it "recovers an interrupted individual asset upload without an Actions artifact" do @publisher.fail_preservation_after = 2 + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) expect(@publisher.remote_digest).to be_nil expect(@publisher.stored_files.keys).to be == ["release.tar", "example-1.0.1.gem"] @publisher.fail_preservation_after = nil receipt = restore @publisher.publish(42) + expect(@publisher.remote_digest).to be == receipt.fetch(:sha256) expect(@publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"] && args[4].end_with?("release.tar")}).to be == 1 expect(@publisher.releases.first.fetch("draft")).to be == false @@ -137,18 +146,21 @@ def restore it "resumes preservation from an available Actions artifact" do originals = Dir.glob(File.join(root, "pkg/*")).to_h{|file| [File.basename(file), File.binread(file)]} @publisher.fail_preservation = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) @publisher.stored_files.merge!(originals) @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] @publisher.fail_preservation = false restore @publisher.publish(42) + expect(@publisher.releases.first.fetch("assets").size).to be == 5 expect(@publisher.remote_digest).to be == Digest::SHA256.hexdigest("Exact signed bytes") end it "requires manual restoration if interrupted before either backup completed" do @publisher.fail_preservation = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) expect{restore}.to raise_exception(RuntimeError, message: be =~ /incomplete/) expect(@publisher.remote_digest).to be_nil @@ -157,12 +169,14 @@ def restore it "rejects a corrupted backup download" do @publisher.publish(42) @publisher.stored_files["release.tar"] = "corrupted" + expect{restore}.to raise_exception(RuntimeError, message: be =~ /backup digest mismatch/) end it "does not overwrite conflicting local files while restoring a backup" do @publisher.publish(42) File.write(File.join(root, "pkg/release.json"), "Local content") + expect(ENV).to receive(:fetch).with("GITHUB_RUN_ID").and_return("123") expect{@publisher.build(42)}.to raise_exception(RuntimeError, message: be =~ /Existing artifact differs/) expect(File.read(File.join(root, "pkg/release.json"))).to be == "Local content" @@ -177,14 +191,17 @@ def restore @publisher.stored_files["release.tar"] = File.binread(archive) @publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}["digest"] = "sha256:#{Digest::SHA256.file(archive).hexdigest}" File.write(package, "Exact signed bytes") + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release backup differs/) end it "restores expired workflow artifacts from the GitHub release" do @publisher.publish(42) @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] + expect(restore.fetch(:sha256)).to be == @publisher.remote_digest @publisher.publish(42) + expect(@publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 end @@ -192,16 +209,19 @@ def restore @publisher.publish(42) @publisher.releases = [] @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] + expect(restore.fetch(:sha256)).to be == @publisher.remote_digest end it "refuses to rebuild when the workflow artifact expired without a release backup" do @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] + expect{restore}.to raise_exception(RuntimeError, message: be =~ /expired/) end it "refuses to rebuild a published version without either backup" do @publisher.remote_digest = @publisher.load_receipt.fetch(:sha256) + expect{restore}.to raise_exception(RuntimeError, message: be =~ /already published/) end @@ -214,6 +234,7 @@ def restore end end receipt = restore + expect(receipt.fetch(:run_id)).to be == "123" expect(receipt.fetch(:sha256)).to be == Digest::SHA256.hexdigest("new gem") end @@ -227,6 +248,7 @@ def restore @publisher.publish(42) @publisher.releases.first.fetch("assets").reject!{|asset| asset.fetch("name") == "release.tar"} @publisher.releases.first.fetch("assets").pop + expect{restore}.to raise_exception(RuntimeError, message: be =~ /incomplete/) end @@ -236,17 +258,20 @@ def restore receipt = JSON.parse(@publisher.stored_files.fetch("release.json")) receipt["commit"] = "b" * 40 @publisher.stored_files["release.json"] = JSON.generate(receipt) + expect{restore}.to raise_exception(RuntimeError, message: be =~ /different commit/) end it "refuses a draft release targeting another commit" do @publisher.releases = [{"tag_name" => "v1.0.1", "draft" => true, "target_commitish" => "b" * 40}] + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /another commit/) expect(@publisher.remote_digest).to be_nil end it "refuses duplicate release records for the same tag" do @publisher.releases = [{"tag_name" => "v1.0.1"}] * 2 + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Multiple GitHub releases/) expect(@publisher.remote_digest).to be_nil end @@ -254,6 +279,7 @@ def restore it "does not overwrite conflicting release assets" do @publisher.publish(42) @publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name").end_with?(".gem")}["digest"] = "sha256:wrong" + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release asset differs/) expect(@publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"]}).to be == 5 end @@ -261,6 +287,7 @@ def restore it "uses the created draft response when the release list remains stale" do @publisher.stale_release_list = true receipt = @publisher.publish(42) + expect(@publisher.releases.size).to be == 1 expect(@publisher.releases.first.fetch("draft")).to be == false expect(@publisher.remote_digest).to be == receipt.fetch(:sha256) @@ -269,11 +296,13 @@ def restore it "restores an existing draft hidden by a stale release list without creating another" do @publisher.fail_release = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) @publisher.stale_release_list = true @publisher.fail_release = false restore @publisher.publish(42) + expect(@publisher.releases.size).to be == 1 expect(@publisher.releases.first.fetch("draft")).to be == false expect(@publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 @@ -286,6 +315,7 @@ def restore original.call(*arguments, **options) end end + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /lookup failed/) expect(@publisher.releases).to be == [] expect(@publisher.remote_digest).to be_nil @@ -293,6 +323,7 @@ def restore it "stops before publication when required attestation verification fails" do @publisher.fail_attestation = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Attestation/) expect(@publisher.remote_digest).to be_nil expect(@publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false @@ -300,12 +331,14 @@ def restore it "refuses a published version containing different bytes" do @publisher.remote_digest = Digest::SHA256.hexdigest("Someone else's artifact") + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /different bytes/) expect(@publisher.commands.any?{|args| args.include?("POST")}).to be == false end it "stops before publication when the receipt attestation is invalid" do @publisher.fail_receipt_verification = true + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Receipt attestation/) expect(@publisher.remote_digest).to be_nil expect(@publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false @@ -316,6 +349,7 @@ def restore receipt = JSON.parse(File.read(path)) receipt["commit"] = "b" * 40 File.write(path, JSON.generate(receipt)) + expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /not for this merged PR/) expect(@publisher.commands).to be == [] end diff --git a/test/bake/gem/github/publisher/registry.rb b/test/bake/gem/github/publisher/registry.rb index 3fbb03e..c6626ae 100644 --- a/test/bake/gem/github/publisher/registry.rb +++ b/test/bake/gem/github/publisher/registry.rb @@ -35,6 +35,7 @@ def verify it "waits for an absent version and a pending download without uploading again" do digests.unshift(nil, Bake::Gem::GitHub::Publisher::RegistryPending.new("pending")) + expect{verify}.not.to raise_exception expect(waits).to be == [10, 10] end @@ -42,30 +43,35 @@ def verify it "waits for an absent registry attestation" do digests.unshift("expected") attestations.unshift(nil) + expect{verify}.not.to raise_exception expect(waits).to be == [10] end it "stops waiting after the bounded number of attempts" do digests.clear + expect{verify}.to raise_exception(RuntimeError, message: be =~ /propagation did not complete/) expect(waits).to be == [10, 10] end it "rejects different bytes immediately" do digests.replace(["different"]) + expect{verify}.to raise_exception(RuntimeError, message: be =~ /different bytes/) expect(waits).to be == [] end it "rejects an unrelated attestation immediately" do attestations.replace([JSON.generate([{bundle: {mediaType: "unrelated"}}])]) + expect{verify}.to raise_exception(RuntimeError, message: be =~ /Sigstore bundle/) expect(waits).to be == [] end it "does not hide registry request errors" do digests.replace([RuntimeError.new("Registry request failed: 403")]) + expect{verify}.to raise_exception(RuntimeError, message: be =~ /403/) expect(waits).to be == [] end @@ -99,18 +105,21 @@ def response(code, body = "") it "recognizes an unpublished version without requesting the missing download" do responses[version_path] = response("404") + expect(publisher.send(:registry_digest, "example", "1.0.1")).to be_nil end it "hashes the actual published package bytes" do responses[version_path] = response("200", "{}") responses[download_path] = response("200", "gem bytes\x00\xff".b) + expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b) end ["403", "500"].each do |code| it "rejects version API errors", unique: code do responses[version_path] = response(code) + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}") end end @@ -118,12 +127,14 @@ def response(code, body = "") it "rejects a forbidden download for an existing version" do responses[version_path] = response("200", "{}") responses[download_path] = response("403") + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403") end it "rejects a missing download for an existing version" do responses[version_path] = response("200", "{}") responses[download_path] = response("404") + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/) end @@ -132,18 +143,21 @@ def response(code, body = "") responses[version_path]["location"] = "/version.json" responses["/version.json"] = response("200", "{}") responses[download_path] = response("200", "gem bytes") + expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes") end it "rejects a redirect to an unencrypted download" do responses[version_path] = response("302") responses[version_path]["location"] = "http://rubygems.org/version.json" + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /requires HTTPS/) end it "bounds registry redirect loops" do responses[version_path] = response("302") responses[version_path]["location"] = version_path + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Too many registry redirects/) end end diff --git a/test/bake/gem/github/release.rb b/test/bake/gem/github/release.rb index 48f4d7f..a2f8919 100644 --- a/test/bake/gem/github/release.rb +++ b/test/bake/gem/github/release.rb @@ -21,6 +21,7 @@ RUBY File.chmod(0755, File.join(bin, "gh")) isolated_project('Bake::Context.load(Dir.pwd).call("gem:github:setup", "checks=Tests", "signing=false")', env: {"PATH" => "#{bin}:#{ENV.fetch('PATH')}"}) + expect(YAML.safe_load_file(File.join(repository, "config/release.yaml"))).to have_keys("repository" => be == "socketry/example", "branch" => be == "main") end end @@ -34,6 +35,7 @@ Sus::Mock.new(Bake::Gem::GitHub::Project).replace(:new){project} Bake::Context.load(Dir.pwd).call("gem:github:release:#{ENV.fetch('BUMP')}") RUBY + expect(result).to be == "https://github.com/socketry/example/pull/42" expect(git("branch", "--show-current")).to be == "releases/v#{version}" end @@ -64,6 +66,7 @@ Sus::Mock.new(Bake::Gem::GitHub::Project).replace(:new){project} context.call("gem:github:release:resolve") RUBY + expect(result.nil?).to be == !release expect(File.readlines(output, chomp: true)).to be == (release ? ["release=true", "commit=#{git('rev-parse', 'HEAD')}"] : ["release=false"]) end @@ -80,12 +83,14 @@ it "reruns the original publishing run" do project.responses["repos/socketry/example/actions/runs/123"] = {"path" => ".github/workflows/release-publish.yaml"} + expect(project).to receive(:system).with("gh", "run", "rerun", "123", "--repo", "socketry/example", chdir: repository).and_return(true) context.call("gem:github:release:resume", "run=123") end it "refuses a run belonging to another workflow" do project.responses["repos/socketry/example/actions/runs/123"] = {"path" => ".github/workflows/test.yaml"} + expect{context.call("gem:github:release:resume", "run=123")}.to raise_exception(RuntimeError, message: be =~ /Expected a release-publish workflow run/) expect(project.writes).to be == [] end diff --git a/test/bake/gem/github/setup.rb b/test/bake/gem/github/setup.rb index cf53358..a0b1ef0 100644 --- a/test/bake/gem/github/setup.rb +++ b/test/bake/gem/github/setup.rb @@ -22,6 +22,7 @@ def generate context = Bake::Context.new(registry, root) context.call("gem:github:setup", "repository=socketry/example", "branch=main", "checks=Tests,RuboCop", "signing=false", "approvals=2") config = YAML.safe_load_file(File.join(root, "config/release.yaml")) + expect(config.fetch("signing")).to be == false expect(config.fetch("approvals")).to be == 2 %w[resolve build publish resume patch minor major].each do |name| @@ -31,14 +32,17 @@ def generate it "generates three parseable workflows and idempotent native policy" do paths = generate + expect(generate).to be == paths expect(File).not.to be(:exist?, File.join(root, ".github/releasing.md")) expect(paths.grep(/workflows/).size).to be == 3 paths.grep(/workflows/).each do |path| workflow = YAML.safe_load_file(File.join(root, path)) + expect(workflow).to have_keys("jobs", "permissions") end checks = JSON.parse(File.read(File.join(root, ".github/release-rules/checks.json"))) + expect(checks.dig("rules", 0, "parameters", "strict_required_status_checks_policy")).to be == true expect(checks.dig("bypass_actors", 0, "bypass_mode")).to be == "pull_request" end @@ -46,10 +50,12 @@ def generate it "keeps unmerged validation read-only and retains artifacts before credentials" do generate validation = File.read(File.join(root, ".github/workflows/release-validate.yaml")) + expect(validation).not.to be(:include?, "secrets.") expect(validation).not.to be(:include?, "id-token") expect(validation).not.to be(:include?, "pull_request_target") publish = File.read(File.join(root, ".github/workflows/release-publish.yaml")) + expect(publish).to be(:include?, "github.event.pull_request.merged == true") expect(publish.index("actions/upload-artifact@")).to be < publish.index("rubygems/configure-rubygems-credentials@") expect(publish).not.to be(:include?, "GEM_SIGNING_KEY") @@ -59,6 +65,7 @@ def generate generate path = File.join(root, ".github/workflows/release-validate.yaml") File.write(path, "Custom workflow\n") + expect{generate}.to raise_exception(RuntimeError, message: be =~ /Existing files differ/) expect(File.read(path)).to be == "Custom workflow\n" end @@ -67,17 +74,21 @@ def generate generate workflow = YAML.safe_load_file(File.join(root, ".github/workflows/release-publish.yaml")) inspect = workflow.fetch("jobs").fetch("inspect") + expect(inspect.fetch("if")).to be == "github.event.pull_request.merged == true" expect(inspect.fetch("steps").first.fetch("with")).not.to have_keys("allow-unsafe-pr-checkout", "ref") expect(inspect.fetch("steps").last.fetch("run")).to be == "bundle exec bake gem:github:release:resolve" publish = workflow.fetch("jobs").fetch("publish") + expect(publish.fetch("needs")).to be == "inspect" expect(publish.fetch("if")).to be == "needs.inspect.outputs.release == 'true'" checkout = publish.fetch("steps").first.fetch("with") + expect(checkout.fetch("ref")).to be == "${{ needs.inspect.outputs.commit }}" expect(checkout.fetch("allow-unsafe-pr-checkout")).to be == true %w[prepare validate].each do |name| workflow = File.read(File.join(root, ".github/workflows/release-#{name}.yaml")) + expect(workflow).not.to be(:include?, "allow-unsafe-pr-checkout") end end @@ -86,6 +97,7 @@ def generate generate workflow = YAML.safe_load_file(File.join(root, ".github/workflows/release-publish.yaml")) attest = workflow.fetch("jobs").fetch("publish").fetch("steps").find{|step| step["id"] == "attest"} + expect(attest.fetch("with")).to be == {"subject-path" => "${{ steps.build.outputs.package }}\npkg/release.json\n"} end end diff --git a/test/bake/gem/github/setup/update.rb b/test/bake/gem/github/setup/update.rb index 33da8ce..5af94c4 100644 --- a/test/bake/gem/github/setup/update.rb +++ b/test/bake/gem/github/setup/update.rb @@ -27,8 +27,10 @@ registry = Bake::Registry::Aggregate.new registry.append_path(::Gem.loaded_specs.fetch("bake-gem-github").full_gem_path) changed = Bake::Context.new(registry, repository).call("gem:github:setup:update") + expect(changed.sort).to be == [".github/release-rules/checks.json", ".github/release-rules/reviews.json", ".github/workflows/release-validate.yaml"] diff = git("diff") + expect(diff).to be(:include?, "-# Custom workflow") expect(diff).to be(:include?, '+ "context": "New check"') expect(git("rev-parse", "HEAD")).to be == original @@ -43,6 +45,7 @@ path = File.join(repository, ".github/workflows/release-validate.yaml") original = File.read(path) File.unlink(path) + expect(setup.update).to be == [".github/workflows/release-validate.yaml"] expect(File.read(path)).to be == original expect(setup.update).to be == [] @@ -51,6 +54,7 @@ it "refuses unsupported configuration schemas" do File.write(File.join(repository, "config/release.yaml"), YAML.dump("schema" => 2)) + expect{setup.update}.to raise_exception(RuntimeError, message: be =~ /Unsupported release configuration/) end end From 6b925683b5dcb7dd810ee1b3944de55c98136dd0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:34:58 +1200 Subject: [PATCH 2/9] Load Bake task dependencies through the gem load path. --- bake/gem/github.rb | 4 ++-- bake/gem/github/release.rb | 10 +++++++--- bake/gem/github/setup.rb | 4 ++-- test/bake/gem/github/release.rb | 22 ++++++++++++++++++++++ 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/bake/gem/github.rb b/bake/gem/github.rb index 6907dfe..889c5af 100644 --- a/bake/gem/github.rb +++ b/bake/gem/github.rb @@ -11,7 +11,7 @@ # @parameter signing [Boolean] Require legacy certificate signing. # @parameter ruby [String] Ruby version for release workflows. def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4") - require_relative "../../lib/bake/gem/github/setup" + require "bake/gem/github/setup" require "bake/gem/shell" helper = Object.new.extend(Bake::Gem::Shell) @@ -35,7 +35,7 @@ def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, rub # Show the desired rules, existing rules, environments, and RubyGems bootstrap values. def doctor - require_relative "../../lib/bake/gem/github/project" + require "bake/gem/github/project" return Bake::Gem::GitHub::Project.new(context.root).doctor end diff --git a/bake/gem/github/release.rb b/bake/gem/github/release.rb index 4db895c..000b394 100644 --- a/bake/gem/github/release.rb +++ b/bake/gem/github/release.rb @@ -3,7 +3,7 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require_relative "../../../lib/bake/gem/github/publisher" +require "bake/gem/github/project" # Prepare a patch release and open its PR. # @parameter refresh [Boolean] Preserve and regenerate an existing release branch. @@ -39,12 +39,16 @@ def resolve(number: ENV.fetch("RELEASE_PR")) # Build or restore the exact artifact for a merged release PR. def build(number: ENV.fetch("RELEASE_PR")) - Bake::Gem::GitHub::Publisher.new(context.root).build(number) + require "bake/gem/github/publisher" + + return Bake::Gem::GitHub::Publisher.new(context.root).build(number) end # Verify, upload and finalize a merged release using its retained artifact. def publish(number: ENV.fetch("RELEASE_PR")) - Bake::Gem::GitHub::Publisher.new(context.root).publish(number) + require "bake/gem/github/publisher" + + return Bake::Gem::GitHub::Publisher.new(context.root).publish(number) end # Rerun the original publishing workflow, preserving event identity and artifact bytes. diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index f0da36c..737f13e 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -10,14 +10,14 @@ def plan # Apply the four managed rulesets using the current gh administrator credentials. def apply - require_relative "../../../lib/bake/gem/github/project" + require "bake/gem/github/project" return Bake::Gem::GitHub::Project.new(context.root).apply end # Update generated files in the working tree using config/release.yaml and the installed templates. def update - require_relative "../../../lib/bake/gem/github/setup" + require "bake/gem/github/setup" return Bake::Gem::GitHub::Setup.new(context.root).update end diff --git a/test/bake/gem/github/release.rb b/test/bake/gem/github/release.rb index a2f8919..95ecf19 100644 --- a/test/bake/gem/github/release.rb +++ b/test/bake/gem/github/release.rb @@ -9,6 +9,28 @@ describe "GitHub release tasks" do include Bake::Gem::GitHub::RepositoryContext + it "discovers preparation tasks without loading the publisher" do + publisher = isolated_ruby(<<~'RUBY', chdir: repository, requires: ["bundler/setup", "bake/context"]) + context = Bake::Context.load + %w[patch minor major].each do |bump| + context.lookup("gem:github:release:#{bump}") or raise "Missing task" + end + defined?(Bake::Gem::GitHub::Publisher) + RUBY + + expect(publisher).to be_nil + end + + %w[build publish].each do |task| + it "loads the publisher when invoking its task", unique: task do + expect do + isolated_ruby(<<~'RUBY', chdir: repository, env: {"TASK" => task, "GITHUB_REPOSITORY" => nil}, requires: ["bundler/setup", "bake/context"]) + Bake::Context.load.call("gem:github:release:#{ENV.fetch('TASK')}", "number=42") + RUBY + end.to raise_exception(RuntimeError, message: be =~ /configured GitHub repository/) + end + end + with "gem:github:setup" do it "discovers the canonical repository and default branch through gh" do bin = File.join(root, "bin") From 7623f80c21afdf7ed21708c6fab5b12ecc1dd02f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:35:55 +1200 Subject: [PATCH 3/9] Document release API contracts and task arguments. --- bake/gem/github.rb | 10 ++++++---- bake/gem/github/release.rb | 11 +++++++++++ bake/gem/github/setup.rb | 3 +++ lib/bake/gem/github/backup.rb | 7 +++++++ lib/bake/gem/github/project.rb | 26 ++++++++++++++++++++++++++ lib/bake/gem/github/publisher.rb | 13 +++++++++++++ lib/bake/gem/github/setup.rb | 12 ++++++++++++ 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/bake/gem/github.rb b/bake/gem/github.rb index 889c5af..8fe5b25 100644 --- a/bake/gem/github.rb +++ b/bake/gem/github.rb @@ -3,13 +3,14 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -# Generate local release workflows, policy, and documentation for review. +# Generate local release workflows, policy payloads, and configuration for review. # @parameter checks [Array(String)] Required CI check names, including matrix entries. -# @parameter repository [String] Canonical owner/repository. -# @parameter branch [String] Default branch name. +# @parameter repository [String] Canonical owner/repository; discovered through GitHub when omitted. +# @parameter branch [String] Default branch name; discovered through GitHub when omitted. # @parameter approvals [Integer] Number of approving reviews. -# @parameter signing [Boolean] Require legacy certificate signing. +# @parameter signing [Boolean] Require certificate signing; when omitted, enable it if `release.cert` exists. # @parameter ruby [String] Ruby version for release workflows. +# @returns [Array(String)] Generated paths relative to the repository root. def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4") require "bake/gem/github/setup" require "bake/gem/shell" @@ -34,6 +35,7 @@ def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, rub end # Show the desired rules, existing rules, environments, and RubyGems bootstrap values. +# @returns [Hash] Desired and observed settings; RubyGems values describe the expected configuration. def doctor require "bake/gem/github/project" diff --git a/bake/gem/github/release.rb b/bake/gem/github/release.rb index 000b394..e2aca8a 100644 --- a/bake/gem/github/release.rb +++ b/bake/gem/github/release.rb @@ -7,23 +7,28 @@ # Prepare a patch release and open its PR. # @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +# @returns [String] The release PR URL. def patch(refresh: false) Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch", refresh: refresh) end # Prepare a minor release and open its PR. # @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +# @returns [String] The release PR URL. def minor(refresh: false) Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor", refresh: refresh) end # Prepare a major release and open its PR. # @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +# @returns [String] The release PR URL. def major(refresh: false) Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major", refresh: refresh) end # Resolve and validate a merged PR, emitting a commit output for the publishing job. +# @parameter number [String] The merged PR number; defaults to `RELEASE_PR`. +# @returns [Hash | Nil] Release metadata, or nil for an ordinary PR. def resolve(number: ENV.fetch("RELEASE_PR")) result = Bake::Gem::GitHub::Project.new(context.root).inspect_release(number) @@ -38,6 +43,8 @@ def resolve(number: ENV.fetch("RELEASE_PR")) end # Build or restore the exact artifact for a merged release PR. +# @parameter number [String] The merged release PR number; defaults to `RELEASE_PR`. +# @returns [Hash] The built or restored release receipt. def build(number: ENV.fetch("RELEASE_PR")) require "bake/gem/github/publisher" @@ -45,6 +52,8 @@ def build(number: ENV.fetch("RELEASE_PR")) end # Verify, upload and finalize a merged release using its retained artifact. +# @parameter number [String] The merged release PR number; defaults to `RELEASE_PR`. +# @returns [Hash] The published release receipt. def publish(number: ENV.fetch("RELEASE_PR")) require "bake/gem/github/publisher" @@ -52,6 +61,8 @@ def publish(number: ENV.fetch("RELEASE_PR")) end # Rerun the original publishing workflow, preserving event identity and artifact bytes. +# @parameter run [String] The original release-publish workflow run ID. +# @returns [Boolean] True when GitHub accepts the rerun request. def resume(run:) project = Bake::Gem::GitHub::Project.new(context.root) details = project.api("actions/runs/#{Integer(run)}") diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index 737f13e..bfa8376 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -4,11 +4,13 @@ # Copyright, 2026, by Samuel Williams. # Inspect external settings before applying the generated policy. +# @returns [Hash] Desired and observed settings for review. def plan context.lookup("gem:github:doctor").call end # Apply the four managed rulesets using the current gh administrator credentials. +# @returns [Hash] The managed ruleset payloads after successful application. def apply require "bake/gem/github/project" @@ -16,6 +18,7 @@ def apply end # Update generated files in the working tree using config/release.yaml and the installed templates. +# @returns [Array(String)] Changed paths relative to the repository root. def update require "bake/gem/github/setup" diff --git a/lib/bake/gem/github/backup.rb b/lib/bake/gem/github/backup.rb index 2db76e5..b55d72a 100644 --- a/lib/bake/gem/github/backup.rb +++ b/lib/bake/gem/github/backup.rb @@ -11,6 +11,9 @@ module GitHub # Stores the original release files together so a single completed upload can recover them. module Backup # Write the release files to a tar archive. + # @parameter path [String] The destination archive path. + # @parameter files [Array(String)] Original release files, stored under their basenames. + # @returns [Nil] After writing and closing the archive. def self.write(path, files) File.open(path, "wb") do |output| ::Gem::Package::TarWriter.new(output) do |archive| @@ -22,6 +25,10 @@ def self.write(path, files) end # Read only the expected regular files; reject missing, duplicate, or unexpected entries before extraction. + # @parameter path [String] The archive to inspect without extracting filesystem paths. + # @parameter names [Array(String)] Exactly the permitted basenames for the gem, receipt, and two attestation files. + # @returns [Hash(String, String)] Binary file contents keyed by basename. + # @raises [RuntimeError] If entries are missing, duplicated, unexpected, or not regular files. def self.read(path, names) files = {} diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 1932b0c..5927e44 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -16,6 +16,8 @@ class Project include Shell # Load the reviewed repository release policy. + # @parameter root [String] The repository root containing `config/release.yaml`. + # @raises [RuntimeError] If the configuration schema is unsupported. def initialize(root) @root = File.expand_path(root) @config = YAML.safe_load_file(File.join(@root, "config/release.yaml")) @@ -28,11 +30,24 @@ def initialize(root) attr_reader :config # Execute a GitHub API read. Failures never imply that a resource is absent. + # @parameter path [String] An API path relative to this repository. + # @returns [Hash | Array] The decoded GitHub response, retaining string keys. + # @raises [Bake::Gem::CommandExecutionError] If the GitHub request fails. def api(path) JSON.parse(readlines("gh", "api", "repos/#{@repository}/#{path}", chdir: @root).join) end # Prepare a release through core Bake tasks, then push and create its pull request. + # + # The process must already be in the repository root because {Bake::Gem::Helper} evaluates its gemspec. + # Refresh preserves the previous release commit before replacing the remote branch with an explicit push lease. + # + # @parameter context [Bake::Context] The consumer context used to invoke core release tasks. + # @parameter bump [String] The stable version increment: `patch`, `minor`, or `major`. + # @parameter refresh [Boolean] Whether to regenerate an existing release from the current base. + # @returns [String] The new or existing release PR URL. + # @raises [RuntimeError] If the checkout, existing PR, or generated release content is unsuitable. + # @raises [Bake::Gem::CommandExecutionError] If a Git or GitHub operation fails, including a conflicting push. def prepare(context, bump, refresh: false) Release::BUMPS.fetch(bump) helper = Helper.new(@root) @@ -106,6 +121,10 @@ def prepare(context, bump, refresh: false) end # Resolve a merged PR through GitHub, and require its actual merge commit in default-branch history. + # @parameter number [String | Integer] The positive PR number. + # @returns [Hash] GitHub PR data with string keys, including `number` and `merge_commit_sha`. + # @raises [RuntimeError] If the PR is invalid, unmerged, or targets another repository or branch. + # @raises [Bake::Gem::CommandExecutionError] If its commit is outside the default branch history or a command fails. def merged(number) raise "Expected a PR number." unless number.to_s.match?(/\A[1-9]\d*\z/) pr = api("pulls/#{number}") @@ -121,6 +140,9 @@ def merged(number) end # Resolve release identity; ordinary merged PRs do not publish. + # @parameter number [String | Integer] The merged PR number. + # @returns [Hash | Nil] Release metadata with symbol keys, or nil for an ordinary PR. Includes `name`, `version`, `commit`, `base`, `bump`, `repository`, `pull_request`, `merged_by`, and `pull_request_url`. + # @raises [RuntimeError] If the merged source does not match the independently generated release. def inspect_release(number) pr = merged(number) @@ -137,6 +159,7 @@ def inspect_release(number) end # Return a read-only comparison of managed settings and current repository settings. + # @returns [Hash] Desired rules, existing rules, environments, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration. def doctor { desired_rules: Setup.rules(@config), @@ -152,6 +175,9 @@ def doctor end # Apply only the named rulesets generated by setup. Invoke after reviewing doctor output. + # @returns [Hash] The desired ruleset payloads after successful application. + # @raises [RuntimeError] If more than one existing ruleset has a managed name. + # @raises [Bake::Gem::CommandExecutionError] If an API operation fails; earlier updates may already have completed. def apply existing = api("rulesets?per_page=100") return Setup.rules(@config).each_value do |rule| diff --git a/lib/bake/gem/github/publisher.rb b/lib/bake/gem/github/publisher.rb index 0b8069f..14f854b 100644 --- a/lib/bake/gem/github/publisher.rb +++ b/lib/bake/gem/github/publisher.rb @@ -20,6 +20,9 @@ class RegistryPending < RuntimeError end # Build or restore this workflow run's artifact, after validating the actual merged commit. + # @parameter number [String | Integer] The merged release PR number. + # @returns [Hash] The release receipt. Extends {Project#inspect_release} metadata with `file`, `sha256`, `run_id`, and `signing`; writes the receipt to `pkg/release.json` and Actions outputs when configured. + # @raises [RuntimeError] If the workflow identity, source, or retained artifact is invalid, or published bytes cannot be recovered. def build(number) guard_environment evidence = inspect_release(number) or raise "PR does not change the version." @@ -89,6 +92,10 @@ def build(number) end # Verify both attestations, upload exactly those bytes, then create only the intended tag and release. + # @parameter number [String | 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. def publish(number) guard_environment @@ -142,6 +149,8 @@ def publish(number) end # Load artifact evidence and verify the stored digest and filename. + # @returns [Hash] The receipt with symbol keys, including the verified `file` and `sha256`. + # @raises [RuntimeError] If the package filename is invalid or its bytes do not match the receipt. def load_receipt receipt = JSON.parse(File.read(File.join(@root, "pkg", "release.json")), symbolize_names: true) filename = receipt.fetch(:file) @@ -152,6 +161,10 @@ def load_receipt end # Refuse local or remote tag collisions before uploading a package. + # @parameter tag [String] The version tag to publish. + # @parameter commit [String] 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. 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 diff --git a/lib/bake/gem/github/setup.rb b/lib/bake/gem/github/setup.rb index 313281a..e2d8777 100644 --- a/lib/bake/gem/github/setup.rb +++ b/lib/bake/gem/github/setup.rb @@ -19,6 +19,14 @@ def initialize(root) end # Generate workflows, policy payloads, and configuration. Refuse conflicting existing files. + # @parameter repository [String] The canonical GitHub owner and repository name. + # @parameter branch [String] The default branch receiving release PRs. + # @parameter checks [Array(String)] Required CI job names; release validation is added automatically. + # @parameter approvals [Integer] Required approvals, between one and six. + # @parameter signing [Boolean] Whether publishing requires the certificate and matching private key. + # @parameter ruby [String] The Ruby version used by release workflows. + # @returns [Array(String)] Generated paths relative to the repository root. + # @raises [RuntimeError] If configuration is invalid or an existing generated file differs. def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.file?(File.join(@root, "release.cert")), ruby: "3.4") raise "Expected owner/repository." unless repository.match?(/\A[\w.-]+\/[\w.-]+\z/) raise "Unsupported branch name." unless branch.match?(/\A[\w.\/-]+\z/) @@ -50,6 +58,8 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f end # Update generated files in the working tree using the existing configuration; return changed paths. + # @returns [Array(String)] Changed paths relative to the repository root. + # @raises [RuntimeError] If the configuration schema is unsupported. def update config = YAML.safe_load_file(File.join(@root, "config/release.yaml")) raise "Unsupported release configuration." unless config.fetch("schema") == 1 @@ -58,6 +68,8 @@ def update end # Native review/check rules allow PR-only administrator bypass; history rules have no bypass. + # @parameter config [Hash] Release configuration with string keys: `branch`, `approvals`, and `checks`. + # @returns [Hash] Ruleset payloads keyed by `reviews`, `checks`, `history`, and `tags`. def self.rules(config) conditions = {ref_name: {include: ["refs/heads/#{config.fetch('branch')}"], exclude: []}} common = {target: "branch", enforcement: "active", conditions: conditions} From a57cc7a1d42ab6cbbd5b709172437967da72931a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:36:40 +1200 Subject: [PATCH 4/9] Separate release preparation into named operations. --- lib/bake/gem/github/project.rb | 126 +++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 5927e44..8d49a63 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -58,39 +58,20 @@ def prepare(context, bump, refresh: false) system("git", "fetch", "origin", branch, "--tags", chdir: @root) raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}") - pulls = JSON.parse(readlines( - "gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", - "--json", "headRefName,url,isCrossRepository", "--limit", "1000", chdir: @root, - ).join) - pulls = pulls.select{|pr| !pr["isCrossRepository"] && pr.fetch("headRefName").start_with?("releases/v")} - raise "Multiple release PRs are open; select one before preparing another release." if pulls.size > 1 - - existing = pulls.first + pull_request = find_release_pull_request(branch) version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join - name = "releases/v#{version}" - raise "Existing release PR uses #{existing.fetch('headRefName')}; use its bump type or close it first." if existing && existing.fetch("headRefName") != name - - base = @release.resolve("HEAD") - ref = "refs/heads/#{name}" - remote = readlines("git", "ls-remote", "--heads", "origin", ref, chdir: @root).first - if remote - system("git", "fetch", "origin", ref, chdir: @root) - remote = @release.resolve("FETCH_HEAD") + release_branch = "releases/v#{version}" + if pull_request && pull_request.fetch("headRefName") != release_branch + raise "Existing release PR uses #{pull_request.fetch('headRefName')}; use its bump type or close it first." end - candidate = remote - if !candidate && readlines("git", "branch", "--list", name, chdir: @root).any? - candidate = @release.resolve(ref) - end + base = @release.resolve("HEAD") + release_ref = "refs/heads/#{release_branch}" + remote_commit = fetch_release_branch(release_ref) + candidate = remote_commit || local_release_commit(release_branch) if candidate && refresh - # Preserve the complete previous tree before replacing the release branch: - backup = "refs/heads/release-backups/v#{version}/#{candidate}" - push("#{candidate}:#{backup}") - candidate = @release.worktree(base) do |path| - @release.bake(path, "gem:release:version:#{bump}") - readlines("git", "rev-parse", "HEAD", chdir: path).join.strip - end + candidate = refresh_release(candidate, base: base, bump: bump, version: version) elsif !candidate context.lookup("gem:release:branch:#{bump}").call candidate = @release.resolve("HEAD") @@ -99,25 +80,13 @@ def prepare(context, bump, refresh: false) metadata = @release.validate(base: base, candidate: candidate) raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version - push("--force-with-lease=#{ref}:#{remote}", "#{candidate}:#{ref}") - return existing.fetch("url") if existing + push("--force-with-lease=#{release_ref}:#{remote_commit}", "#{candidate}:#{release_ref}") + return pull_request.fetch("url") if pull_request - body = <<~BODY - Release #{helper.gemspec.name} #{version}. - - Prepared from #{base}. The complete release tree is regenerated during validation. \ - Merging publishes the resulting commit through release-publish.yaml after native \ - reviews and required CI (or explicit administrator bypass). - BODY - - return Tempfile.create("release-pr") do |file| - file.write(body) - file.flush - readlines( - "gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", name, - "--title", "Release v#{version}", "--body-file", file.path, chdir: @root, - ).join.strip - end + return create_release_pull_request( + helper.gemspec.name, version, + branch: branch, release_branch: release_branch, base: base, + ) end # Resolve a merged PR through GitHub, and require its actual merge commit in default-branch history. @@ -197,6 +166,71 @@ def apply private + # Find the repository's sole release PR, excluding forks. + def find_release_pull_request(branch) + response = readlines( + "gh", "pr", "list", "--repo", @repository, "--base", branch, + "--state", "open", "--json", "headRefName,url,isCrossRepository", + "--limit", "1000", chdir: @root, + ) + pull_requests = JSON.parse(response.join).select do |pull_request| + !pull_request["isCrossRepository"] && pull_request.fetch("headRefName").start_with?("releases/v") + end + raise "Multiple release PRs are open; select one before preparing another release." if pull_requests.size > 1 + + return pull_requests.first + end + + # Fetch the remote branch and return the commit used for the push lease. + def fetch_release_branch(reference) + remote = readlines("git", "ls-remote", "--heads", "origin", reference, chdir: @root) + return nil if remote.empty? + + system("git", "fetch", "origin", reference, chdir: @root) + + return @release.resolve("FETCH_HEAD") + end + + # Locate preparation which stopped before pushing its branch. + def local_release_commit(branch) + return nil if readlines("git", "branch", "--list", branch, chdir: @root).empty? + + return @release.resolve("refs/heads/#{branch}") + end + + # Preserve the previous release before regenerating it from the current base. + def refresh_release(candidate, base:, bump:, version:) + backup_ref = "refs/heads/release-backups/v#{version}/#{candidate}" + push("#{candidate}:#{backup_ref}") + + return @release.worktree(base) do |path| + @release.bake(path, "gem:release:version:#{bump}") + readlines("git", "rev-parse", "HEAD", chdir: path).join.strip + end + end + + # Open a PR for the already validated and pushed release branch. + def create_release_pull_request(name, version, branch:, release_branch:, base:) + body = <<~BODY + Release #{name} #{version}. + + Prepared from #{base}. The complete release tree is regenerated during validation. \ + Merging publishes the resulting commit through release-publish.yaml after native \ + reviews and required CI (or explicit administrator bypass). + BODY + + return Tempfile.create("release-pr") do |file| + file.write(body) + file.flush + + readlines( + "gh", "pr", "create", "--repo", @repository, "--base", branch, + "--head", release_branch, "--title", "Release v#{version}", + "--body-file", file.path, chdir: @root, + ).join.strip + end + end + def push(*arguments) system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", *arguments, chdir: @root) end From a7fe70d2c93e0f39648104975004c26b02022a48 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:47:20 +1200 Subject: [PATCH 5/9] Separate publishing, artifact recovery, and registry verification. --- .../bake/gem/github/recovery_publisher.rb | 26 +- lib/bake/gem/github/publisher.rb | 351 +++++++++--------- lib/bake/gem/github/registry.rb | 104 ++++++ .../gem/github/{publisher => }/registry.rb | 41 +- 4 files changed, 320 insertions(+), 202 deletions(-) create mode 100644 lib/bake/gem/github/registry.rb rename test/bake/gem/github/{publisher => }/registry.rb (72%) diff --git a/fixtures/bake/gem/github/recovery_publisher.rb b/fixtures/bake/gem/github/recovery_publisher.rb index b4344c2..ff1c77b 100644 --- a/fixtures/bake/gem/github/recovery_publisher.rb +++ b/fixtures/bake/gem/github/recovery_publisher.rb @@ -8,6 +8,23 @@ module Bake module Gem module GitHub + # Use the real propagation verifier against simulated published content. + class RecoveryRegistry < Registry + def initialize(publisher) + @publisher = publisher + end + + def digest(name, version) + @publisher.remote_digest + end + + private + + def get(url) + JSON.generate([{bundle: {mediaType: "test"}}]) + end + end + # A simulated registry and GitHub finalizer. Core content validation has its own # real-repository integration tests; this fixture exercises interruption/retry. class RecoveryPublisher < Publisher @@ -16,7 +33,7 @@ class RecoveryPublisher < Publisher attr_reader :commands, :stored_files def initialize(root) - super + super(root, registry: RecoveryRegistry.new(self)) @commands = [] @releases = [] @artifacts = [] @@ -96,13 +113,6 @@ def gem_command(*arguments) def guard_environment end - def registry_digest(name, version) - @remote_digest - end - - def registry_get(url) - JSON.generate([{bundle: {mediaType: "test"}}]) - end end end end diff --git a/lib/bake/gem/github/publisher.rb b/lib/bake/gem/github/publisher.rb index 14f854b..c406289 100644 --- a/lib/bake/gem/github/publisher.rb +++ b/lib/bake/gem/github/publisher.rb @@ -5,9 +5,9 @@ require_relative "project" require_relative "backup" +require_relative "registry" require "bake/releases" require "digest" -require "net/http" require "openssl" module Bake @@ -15,8 +15,15 @@ module Gem module GitHub # Builds one artifact, preserves it before upload, and resumes without moving existing tags. class Publisher < Project - # A registered package is not yet available from the download service. - class RegistryPending < RuntimeError + # Compatibility name for a registered package whose download is pending. + RegistryPending = Registry::Pending + + # Load repository policy and the registry used to verify published artifacts. + # @parameter root [String] The repository root containing `config/release.yaml`. + # @parameter registry [Registry] The registry providing package digests and attestation verification. + def initialize(root, registry: Registry.new) + super(root) + @registry = registry end # Build or restore this workflow run's artifact, after validating the actual merged commit. @@ -39,54 +46,15 @@ def build(number) 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)}") - 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) - - contents.each do |name, content| - file = File.join(path, name) - raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content - end - - contents.each do |name, content| - File.binwrite(File.join(path, name), content) - end - else - names = release.fetch("assets").map{|asset| asset.fetch("name")} - raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))} - system( - "gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, - "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, - chdir: @root, - ) - end + restore_release(release, evidence, path) elsif retained raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying." else - 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") + receipt = build_receipt(evidence, path, run) return output(receipt, restored: false) end - receipt = load_receipt - [:name, :version, :commit, :repository, :pull_request].each do |key| - raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key] - end + receipt = restored_receipt(evidence) return output(receipt, restored: true) end @@ -98,52 +66,28 @@ def build(number) # @raises [Bake::Gem::CommandExecutionError] If a verification or publishing command fails; rerunning resumes from retained artifacts. def publish(number) guard_environment - receipt = load_receipt - pr = merged(number) - raise "Artifact is not for this merged PR." unless receipt[:commit] == pr.fetch("merge_commit_sha") && receipt[:pull_request] == pr.fetch("number") && receipt[:repository] == @repository - raise "Checkout must match the artifact source." unless @release.resolve("HEAD") == receipt[:commit] - metadata = @release.validate(base: "#{receipt[:commit]}^1", candidate: receipt[:commit]) - [:name, :version, :commit].each do |key| - raise "Artifact #{key} differs from the merged source." unless receipt[key] == metadata[key] - end + verify_source(receipt, number) package = File.join(@root, "pkg", receipt.fetch(:file)) - verify_certificate(package) if @config.fetch("signing") - bundle = "#{package}.sigstore.json" - 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", - ) - verify_provenance(package) + 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)) + 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 - # Keep verified bytes independently of workflow attempts before uploading: + # 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 - verify_registry(receipt, bundle) - unless readlines("git", "tag", "--list", tag, chdir: @root).any? - system("git", "tag", tag, receipt.fetch(:commit), chdir: @root) - end - system( - "git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", - "push", "origin", "refs/tags/#{tag}", chdir: @root, - ) - if release.fetch("draft") - system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root) - end + @registry.verify(receipt, bundle) + finalize_release(release, tag, receipt.fetch(:commit)) return receipt end @@ -176,6 +120,159 @@ def guard_tag(tag, commit) private + # Restore original files from a complete archive or a legacy set of assets. + 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 + + # Build only an unpublished version and retain the source identity and package digest. + 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 + + # Compare recovered evidence with the independently validated release source. + 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 + + # Bind the receipt to the actual merged PR and regenerated release content. + 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 + + # Verify the optional certificate signature and both attestation formats. + 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 + + # Publish the version tag and draft only after registry verification completes. + 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 + + # Create a draft using notes from the exact release checkout. + 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 + + # Refuse to overwrite individual assets containing different bytes. + 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 + + # Preserve the complete set before uploading individual assets. + 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 + def github_release(tag) pages = JSON.parse(readlines("gh", "api", "--paginate", "--slurp", "repos/#{@repository}/releases?per_page=100", chdir: @root).join) matches = pages.flatten(1).select{|release| release.fetch("tag_name") == tag} @@ -209,47 +306,13 @@ def release_files(receipt) def preserve_release(receipt) tag = "v#{receipt.fetch(:version)}" - release = github_release(tag) - unless release - 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" - 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 - # The release list can remain stale after a successful creation: - release = JSON.parse(readlines( - "gh", "api", "repos/#{@repository}/releases", "--method", "POST", "--input", - file.path, chdir: @root, - ).join) - end - end - + release = github_release(tag) || create_draft(receipt, tag) guard_release(release, receipt.fetch(:commit)) assets = release.fetch("assets") files = release_files(receipt) - - files.each do |file| - if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)} - raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}" - end - end - - if backup = assets.find{|asset| asset.fetch("name") == "release.tar"} - contents = read_backup(release, backup, files) - raise "Existing release backup differs." unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)} - else - # A complete backup needs only one successful upload, before individual assets: - backup = File.join(@root, "pkg/release.tar") - Backup.write(backup, files) - system("gh", "release", "upload", tag, backup, "--repo", @repository, chdir: @root) - end + verify_assets(assets, files) + preserve_backup(release, files) files.each do |file| unless assets.any?{|asset| asset.fetch("name") == File.basename(file)} @@ -271,29 +334,6 @@ def read_backup(release, asset, files) end end - def verify_registry(receipt, bundle, attempts: 7, delay: 10) - local_bundle = JSON.parse(File.read(bundle)) - attempts.times do |attempt| - begin - if digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version)) - raise "Published version has different bytes." unless digest == receipt.fetch(:sha256) - if attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json") - raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(JSON.parse(attestations), local_bundle) - return - end - end - rescue RegistryPending - # The version API can become visible before the gem download: - end - - if attempt < attempts - 1 - Console.info(self, "Waiting for RubyGems to serve the release.") - sleep(delay) - end - end - raise "Registry propagation did not complete; rerun this workflow to resume from the retained release." - end - def gem_command(*arguments) if defined?(::Bundler) ::Bundler.with_unbundled_env{system("gem", *arguments, chdir: @root)} @@ -307,18 +347,6 @@ def guard_environment raise "Publishing requires the default branch workflow." unless ENV["GITHUB_REF"] == "refs/heads/#{@config.fetch('branch')}" end - def contains_bundle?(value, bundle) - return true if value == bundle - case value - when Hash - return value.values.any?{|child| contains_bundle?(child, bundle)} - when Array - return value.any?{|child| contains_bundle?(child, bundle)} - else - return false - end - end - def verify_certificate(path) policy = ::Gem::Security::Policy.new("Release", only_trusted: false) package = ::Gem::Package.new(path, policy) @@ -364,8 +392,9 @@ def output(receipt, restored:) def verify_provenance(package) ref = "refs/heads/#{@config.fetch('branch')}" identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@#{ref}" + # The signed receipt binds the package digest to the release commit, independently of the workflow revision: - [package, File.join(@root, "pkg", "release.json")].each do |file| + return [package, File.join(@root, "pkg", "release.json")].each do |file| system( "gh", "attestation", "verify", file, "--repo", @repository, "--bundle", File.join(@root, "pkg", "provenance.sigstore.json"), "--cert-identity", identity, @@ -373,32 +402,6 @@ def verify_provenance(package) ) end end - - def registry_digest(name, version) - # Missing downloads can return 403; use the version API to establish absence: - return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby") - - body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem") - raise RegistryPending, "Published gem download is missing; retry after registry propagation." unless body - - return Digest::SHA256.hexdigest(body) - end - - def registry_get(url, redirects: 5) - uri = URI(url) - raise "Registry redirect requires HTTPS." unless uri.scheme == "https" - - response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60){|http| http.get(uri.request_uri)} - return nil if response.is_a?(Net::HTTPNotFound) - - if response.is_a?(Net::HTTPRedirection) - raise "Too many registry redirects." unless redirects > 0 - return registry_get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1) - end - raise "Registry request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess) - - return response.body - end end end end diff --git a/lib/bake/gem/github/registry.rb b/lib/bake/gem/github/registry.rb new file mode 100644 index 0000000..5e629f1 --- /dev/null +++ b/lib/bake/gem/github/registry.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "console" +require "digest" +require "json" +require "net/http" + +module Bake + module Gem + module GitHub + # Reads RubyGems package bytes and verifies registry propagation of the release attestations. + class Registry + # A registered package is not yet available from the download service. + class Pending < RuntimeError + end + + # Configure the HTTP transport used for registry requests. + # @parameter http [Interface(:start)] The transport providing Net::HTTP-compatible sessions. + def initialize(http: Net::HTTP) + @http = http + end + + # Compute a published package's digest, distinguishing absence from propagation delays. + # @parameter name [String] The gem name. + # @parameter version [String] The stable gem version. + # @returns [String | Nil] The SHA256 digest, or nil if the version is not registered. + # @raises [Pending] If the version is registered but its download is absent. + # @raises [RuntimeError] If a request fails or a redirect violates the HTTPS policy. + def digest(name, version) + # Missing downloads can return 403; use the version API to establish absence: + return nil unless get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby") + + body = get("https://rubygems.org/downloads/#{name}-#{version}.gem") + raise Pending, "Published gem download is missing; retry after registry propagation." unless body + + return Digest::SHA256.hexdigest(body) + end + + # Wait for the published bytes and attestation to match the retained release. + # @parameter receipt [Hash] Symbol-keyed release evidence containing `name`, `version`, and `sha256`. + # @parameter bundle [String] The local Sigstore bundle path. + # @parameter attempts [Integer] The maximum number of verification attempts. + # @parameter delay [Numeric] Seconds to wait between attempts. + # @returns [Nil] When both the bytes and attestation match. + # @raises [RuntimeError] If verification fails or registry propagation times out. + def verify(receipt, bundle, attempts: 7, delay: 10) + local_bundle = JSON.parse(File.read(bundle)) + attempts.times do |attempt| + begin + if remote_digest = digest(receipt.fetch(:name), receipt.fetch(:version)) + raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256) + if attestations = get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json") + raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(JSON.parse(attestations), local_bundle) + return + end + end + rescue Pending + # The version API can become visible before the gem download. + end + + if attempt < attempts - 1 + Console.info(self, "Waiting for RubyGems to serve the release.") + sleep(delay) + end + end + raise "Registry propagation did not complete; rerun this workflow to resume from the retained release." + end + + private + + def get(url, redirects: 5) + uri = URI(url) + raise "Registry redirect requires HTTPS." unless uri.scheme == "https" + + response = @http.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60){|http| http.get(uri.request_uri)} + return nil if response.is_a?(Net::HTTPNotFound) + + if response.is_a?(Net::HTTPRedirection) + raise "Too many registry redirects." unless redirects > 0 + return get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1) + end + raise "Registry request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + return response.body + end + + def contains_bundle?(value, bundle) + return true if value == bundle + case value + when Hash + return value.values.any?{|child| contains_bundle?(child, bundle)} + when Array + return value.any?{|child| contains_bundle?(child, bundle)} + else + return false + end + end + end + end + end +end diff --git a/test/bake/gem/github/publisher/registry.rb b/test/bake/gem/github/registry.rb similarity index 72% rename from test/bake/gem/github/publisher/registry.rb rename to test/bake/gem/github/registry.rb index c6626ae..878f993 100644 --- a/test/bake/gem/github/publisher/registry.rb +++ b/test/bake/gem/github/registry.rb @@ -3,13 +3,13 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "bake/gem/github/publisher" +require "bake/gem/github/registry" require "sus/fixtures/temporary_directory_context" -describe Bake::Gem::GitHub::Publisher do - with "#verify_registry" do +describe Bake::Gem::GitHub::Registry do + with "#verify" do include Sus::Fixtures::TemporaryDirectoryContext - let(:publisher) {subject.allocate} + let(:registry) {subject.new} let(:receipt) {{name: "example", version: "1.0.1", sha256: "expected"}} let(:bundle) {File.join(root, "bundle.json")} let(:digests) {["expected"]} @@ -18,23 +18,23 @@ before do File.write(bundle, JSON.generate(mediaType: "test")) - mock(publisher) do |wrapper| - wrapper.replace(:registry_digest) do |*arguments| + mock(registry) do |wrapper| + wrapper.replace(:digest) do |*arguments| result = digests.shift raise result if result.is_a?(Exception) result end - wrapper.replace(:registry_get){|url| attestations.shift} + wrapper.replace(:get){|url| attestations.shift} wrapper.replace(:sleep){|delay| waits << delay} end end def verify - publisher.send(:verify_registry, receipt, bundle, attempts: 3, delay: 10) + registry.verify(receipt, bundle, attempts: 3, delay: 10) end it "waits for an absent version and a pending download without uploading again" do - digests.unshift(nil, Bake::Gem::GitHub::Publisher::RegistryPending.new("pending")) + digests.unshift(nil, Bake::Gem::GitHub::Registry::Pending.new("pending")) expect{verify}.not.to raise_exception expect(waits).to be == [10, 10] @@ -77,8 +77,9 @@ def verify end end - with "#registry_digest" do - let(:publisher) {subject.allocate} + with "#digest" do + let(:transport) {Object.new} + let(:registry) {subject.new(http: transport)} let(:http) {Object.new} let(:responses) {{}} let(:version_path) {"/api/v2/rubygems/example/versions/1.0.1.json?platform=ruby"} @@ -96,7 +97,7 @@ def response(code, body = "") mock(http) do |wrapper| wrapper.replace(:get){|path| responses.fetch(path)} end - mock(Net::HTTP) do |wrapper| + mock(transport) do |wrapper| wrapper.replace(:start) do |*arguments, **options, &block| block.call(http) end @@ -106,21 +107,21 @@ def response(code, body = "") it "recognizes an unpublished version without requesting the missing download" do responses[version_path] = response("404") - expect(publisher.send(:registry_digest, "example", "1.0.1")).to be_nil + expect(registry.digest("example", "1.0.1")).to be_nil end it "hashes the actual published package bytes" do responses[version_path] = response("200", "{}") responses[download_path] = response("200", "gem bytes\x00\xff".b) - expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b) + expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b) end ["403", "500"].each do |code| it "rejects version API errors", unique: code do responses[version_path] = response(code) - expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}") + expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}") end end @@ -128,14 +129,14 @@ def response(code, body = "") responses[version_path] = response("200", "{}") responses[download_path] = response("403") - expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403") + expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403") end it "rejects a missing download for an existing version" do responses[version_path] = response("200", "{}") responses[download_path] = response("404") - expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/) + expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/) end it "follows HTTPS redirects relative to the registry URL" do @@ -144,21 +145,21 @@ def response(code, body = "") responses["/version.json"] = response("200", "{}") responses[download_path] = response("200", "gem bytes") - expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes") + expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes") end it "rejects a redirect to an unencrypted download" do responses[version_path] = response("302") responses[version_path]["location"] = "http://rubygems.org/version.json" - expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /requires HTTPS/) + expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /requires HTTPS/) end it "bounds registry redirect loops" do responses[version_path] = response("302") responses[version_path]["location"] = version_path - expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Too many registry redirects/) + expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Too many registry redirects/) end end end From 63fb83d0d70733f995801ed367bb31ecbcdd0a08 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:49:16 +1200 Subject: [PATCH 6/9] Test registry and publication through initialized interfaces. --- .../bake/gem/github/publication_context.rb | 42 +++ .../bake/gem/github/recovery_publisher.rb | 42 +-- fixtures/bake/gem/github/recovery_registry.rb | 29 ++ test/bake/gem/github/publisher/provenance.rb | 28 +- test/bake/gem/github/publisher/recovery.rb | 253 +++++++++--------- test/bake/gem/github/registry.rb | 147 +++++----- 6 files changed, 305 insertions(+), 236 deletions(-) create mode 100644 fixtures/bake/gem/github/publication_context.rb create mode 100644 fixtures/bake/gem/github/recovery_registry.rb diff --git a/fixtures/bake/gem/github/publication_context.rb b/fixtures/bake/gem/github/publication_context.rb new file mode 100644 index 0000000..95dfc43 --- /dev/null +++ b/fixtures/bake/gem/github/publication_context.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "sus/shared" +require "sus/fixtures/temporary_directory_context" +require "bake/gem/github/recovery_publisher" + +module Bake + module Gem + module GitHub + PublicationContext = Sus::Shared("a retained release") do + include Sus::Fixtures::TemporaryDirectoryContext + + let(:publisher) {RecoveryPublisher.new(root)} + let(:receipt) do + { + name: "example", + version: "1.0.1", + file: "example-1.0.1.gem", + sha256: Digest::SHA256.hexdigest("Exact signed bytes"), + commit: "a" * 40, + repository: "socketry/example", + pull_request: 42, + pull_request_url: "https://github.com/socketry/example/pull/42", + } + end + + before do + Setup.new(root).generate(repository: "socketry/example", checks: ["Test"], signing: false) + + FileUtils.mkdir_p(File.join(root, "pkg")) + File.write(File.join(root, "pkg", receipt.fetch(:file)), "Exact signed bytes") + File.write(File.join(root, "pkg", "#{receipt.fetch(:file)}.sigstore.json"), JSON.generate(mediaType: "test")) + File.write(File.join(root, "pkg", "provenance.sigstore.json"), "{}") + File.write(File.join(root, "pkg", "release.json"), JSON.generate(receipt)) + end + end + end + end +end diff --git a/fixtures/bake/gem/github/recovery_publisher.rb b/fixtures/bake/gem/github/recovery_publisher.rb index ff1c77b..1fecc27 100644 --- a/fixtures/bake/gem/github/recovery_publisher.rb +++ b/fixtures/bake/gem/github/recovery_publisher.rb @@ -4,27 +4,11 @@ # Copyright, 2026, by Samuel Williams. require "bake/gem/github/publisher" +require "bake/gem/github/recovery_registry" module Bake module Gem module GitHub - # Use the real propagation verifier against simulated published content. - class RecoveryRegistry < Registry - def initialize(publisher) - @publisher = publisher - end - - def digest(name, version) - @publisher.remote_digest - end - - private - - def get(url) - JSON.generate([{bundle: {mediaType: "test"}}]) - end - end - # A simulated registry and GitHub finalizer. Core content validation has its own # real-repository integration tests; this fixture exercises interruption/retry. class RecoveryPublisher < Publisher @@ -53,6 +37,7 @@ def merged(number) def system(*arguments, **options) @commands << arguments + raise "Attestation verification failed" if @fail_attestation && arguments[0, 3] == ["gem", "exec", "sigstore-cli:0.2.3"] if @fail_receipt_verification && arguments[0, 3] == ["gh", "attestation", "verify"] && arguments[3].end_with?("/release.json") raise "Receipt attestation verification failed" @@ -76,8 +61,16 @@ def system(*arguments, **options) when ["gh", "release", "edit"] raise "GitHub unavailable after upload" if @fail_release @releases.first["draft"] = false + when ["gem", "exec", "sigstore-cli:0.2.3"], ["gh", "attestation", "verify"] + # The fixture records signature verification without calling external tools. + else + unless arguments[0, 2] == ["gem", "push"] || arguments[0, 2] == ["git", "tag"] || + (arguments.first == "git" && arguments.include?("push")) + raise "Unexpected command: #{arguments.inspect}" + end end - true + + return true end def readlines(*arguments, **options) @@ -93,15 +86,24 @@ def readlines(*arguments, **options) @releases << release return [JSON.generate(release)] end + raise "Unexpected GitHub request: #{arguments.inspect}" unless arguments[2, 2] == ["--paginate", "--slurp"] + releases = @releases.each_with_index.map{|release, index| release.merge("id" => index + 1)} return [JSON.generate([@stale_release_list ? [] : releases])] end - [] + + if arguments[0, 3] == ["git", "tag", "--list"] || arguments[0, 3] == ["git", "ls-remote", "--tags"] + return [] + end + + raise "Unexpected command: #{arguments.inspect}" end def api(path) return @releases.fetch(Integer(path.delete_prefix("releases/")) - 1) if path.start_with?("releases/") - {"artifacts" => @artifacts} + return {"artifacts" => @artifacts} if path.match?(%r{\Aactions/runs/\d+/artifacts\?per_page=100\z}) + + raise "Unexpected API path: #{path}" end private diff --git a/fixtures/bake/gem/github/recovery_registry.rb b/fixtures/bake/gem/github/recovery_registry.rb new file mode 100644 index 0000000..ba8f42c --- /dev/null +++ b/fixtures/bake/gem/github/recovery_registry.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/registry" + +module Bake + module Gem + module GitHub + # Use the real propagation verifier against simulated published content. + class RecoveryRegistry < Registry + def initialize(publisher) + @publisher = publisher + end + + def digest(name, version) + @publisher.remote_digest + end + + private + + def get(url) + JSON.generate([{bundle: {mediaType: "test"}}]) + end + end + end + end +end diff --git a/test/bake/gem/github/publisher/provenance.rb b/test/bake/gem/github/publisher/provenance.rb index e4539ec..782c088 100644 --- a/test/bake/gem/github/publisher/provenance.rb +++ b/test/bake/gem/github/publisher/provenance.rb @@ -3,37 +3,31 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "bake/gem/github/publisher" +require "bake/gem/github/publication_context" describe Bake::Gem::GitHub::Publisher do - with "#verify_provenance" do - let(:publisher) do - instance = subject.allocate - instance.instance_variable_set(:@root, "/release") - instance.instance_variable_set(:@repository, "socketry/example") - instance.instance_variable_set(:@config, {"branch" => "main"}) - instance - end - + include Bake::Gem::GitHub::PublicationContext + + with "#publish" do it "verifies both package and receipt with the exact publishing identity" do commands = [] mock(publisher) do |wrapper| - wrapper.replace(:system) do |*arguments, **options| - commands << [arguments, options] - true + wrapper.wrap(:system) do |original, *arguments, **options| + commands << [arguments, options] if arguments[0, 3] == ["gh", "attestation", "verify"] + original.call(*arguments, **options) end end - publisher.send(:verify_provenance, "/release/pkg/example.gem") + publisher.publish(42) options = [ - "--repo", "socketry/example", "--bundle", "/release/pkg/provenance.sigstore.json", + "--repo", "socketry/example", "--bundle", File.join(root, "pkg/provenance.sigstore.json"), "--cert-identity", "https://github.com/socketry/example/.github/workflows/release-publish.yaml@refs/heads/main", "--source-ref", "refs/heads/main", "--deny-self-hosted-runners" ] expect(commands).to be == [ - [["gh", "attestation", "verify", "/release/pkg/example.gem", *options], {chdir: "/release"}], - [["gh", "attestation", "verify", "/release/pkg/release.json", *options], {chdir: "/release"}] + [["gh", "attestation", "verify", File.join(root, "pkg", receipt.fetch(:file)), *options], {chdir: root}], + [["gh", "attestation", "verify", File.join(root, "pkg/release.json"), *options], {chdir: root}] ] end end diff --git a/test/bake/gem/github/publisher/recovery.rb b/test/bake/gem/github/publisher/recovery.rb index 1f77e85..1cfa454 100644 --- a/test/bake/gem/github/publisher/recovery.rb +++ b/test/bake/gem/github/publisher/recovery.rb @@ -3,30 +3,17 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "bake/gem/github/recovery_publisher" -require "sus/fixtures/temporary_directory_context" +require "bake/gem/github/publication_context" require "bake/context" describe "Publication recovery" do - include Sus::Fixtures::TemporaryDirectoryContext - - def before - @root = root - Bake::Gem::GitHub::Setup.new(root).generate(repository: "socketry/example", checks: ["Test"], signing: false) - FileUtils.mkdir_p(File.join(root, "pkg")) - File.write(File.join(root, "pkg", "example-1.0.1.gem"), "Exact signed bytes") - File.write(File.join(root, "pkg", "example-1.0.1.gem.sigstore.json"), JSON.generate(mediaType: "test")) - File.write(File.join(root, "pkg", "provenance.sigstore.json"), "{}") - receipt = {name: "example", version: "1.0.1", file: "example-1.0.1.gem", sha256: Digest::SHA256.hexdigest("Exact signed bytes"), commit: "a" * 40, repository: "socketry/example", pull_request: 42, pull_request_url: "https://github.com/socketry/example/pull/42"} - File.write(File.join(root, "pkg", "release.json"), JSON.generate(receipt)) - @publisher = Bake::Gem::GitHub::RecoveryPublisher.new(root) - end + include Bake::Gem::GitHub::PublicationContext def restore - FileUtils.rm_rf(File.join(@root, "pkg")) + FileUtils.rm_rf(File.join(root, "pkg")) expect(ENV).to receive(:fetch).with("GITHUB_RUN_ID").and_return("123") - expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(@publisher) + expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(publisher) Bake::Context.load(root).call("gem:github:release:build", "number=42") end @@ -55,9 +42,9 @@ def restore Older release. MARKDOWN - receipt = @publisher.publish(42) + receipt = publisher.publish(42) - expect(@publisher.releases.first.fetch("body")).to be == <<~MARKDOWN + expect(publisher.releases.first.fetch("body")).to be == <<~MARKDOWN - Fixed a `bug`. ## Details @@ -72,10 +59,10 @@ def restore end it "uses the artifact metadata when release notes are missing" do - receipt = @publisher.publish(42) + receipt = publisher.publish(42) - expect(@publisher.releases.first.fetch("name")).to be == "v1.0.1" - expect(@publisher.releases.first.fetch("body")).to be == <<~MARKDOWN + expect(publisher.releases.first.fetch("name")).to be == "v1.0.1" + expect(publisher.releases.first.fetch("body")).to be == <<~MARKDOWN https://github.com/socketry/example/pull/42 Source: #{receipt.fetch(:commit)} @@ -85,148 +72,148 @@ def restore it "preserves the existing draft description when finalization is retried" do File.write(File.join(root, "releases.md"), "## v1.0.1\n\nOriginal notes.\n") - @publisher.fail_release = true + publisher.fail_release = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) - body = @publisher.releases.first.fetch("body") + "\nMaintainer addition.\n" - @publisher.releases.first["body"] = body - @publisher.fail_release = false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) + body = publisher.releases.first.fetch("body") + "\nMaintainer addition.\n" + publisher.releases.first["body"] = body + publisher.fail_release = false restore - @publisher.publish(42) + publisher.publish(42) - expect(@publisher.releases.size).to be == 1 - expect(@publisher.releases.first.fetch("body")).to be == body - expect(@publisher.releases.first.fetch("draft")).to be == false + expect(publisher.releases.size).to be == 1 + expect(publisher.releases.first.fetch("body")).to be == body + expect(publisher.releases.first.fetch("draft")).to be == false end it "resumes finalization after upload without uploading or rebuilding again" do - @publisher.fail_release = true + publisher.fail_release = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) - @publisher.fail_release = false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) + publisher.fail_release = false restore - expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(@publisher) + expect(Bake::Gem::GitHub::Publisher).to receive(:new).with(root).and_return(publisher) Bake::Context.load(root).call("gem:github:release:publish", "number=42") - uploads = @publisher.commands.select{|args| args[0, 2] == ["gem", "push"]} + uploads = publisher.commands.select{|args| args[0, 2] == ["gem", "push"]} expect(uploads.size).to be == 1 expect(uploads.first).to be(:include?, "--attestation") - expect(@publisher.commands.any?{|args| args.include?("push") && args.include?("--tags")}).to be == false - expect(@publisher.releases.first.fetch("draft")).to be == false - preserve = @publisher.commands.index{|args| args[0, 3] == ["gh", "release", "upload"]} - upload = @publisher.commands.index{|args| args[0, 2] == ["gem", "push"]} + expect(publisher.commands.any?{|args| args.include?("push") && args.include?("--tags")}).to be == false + expect(publisher.releases.first.fetch("draft")).to be == false + preserve = publisher.commands.index{|args| args[0, 3] == ["gh", "release", "upload"]} + upload = publisher.commands.index{|args| args[0, 2] == ["gem", "push"]} expect(preserve).to be < upload end it "does not upload to RubyGems if draft asset preservation fails" do - @publisher.fail_preservation = true + publisher.fail_preservation = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) - expect(@publisher.remote_digest).to be_nil + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) + expect(publisher.remote_digest).to be_nil end it "recovers an interrupted individual asset upload without an Actions artifact" do - @publisher.fail_preservation_after = 2 + publisher.fail_preservation_after = 2 - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) - expect(@publisher.remote_digest).to be_nil - expect(@publisher.stored_files.keys).to be == ["release.tar", "example-1.0.1.gem"] - @publisher.fail_preservation_after = nil + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) + expect(publisher.remote_digest).to be_nil + expect(publisher.stored_files.keys).to be == ["release.tar", "example-1.0.1.gem"] + publisher.fail_preservation_after = nil receipt = restore - @publisher.publish(42) + publisher.publish(42) - expect(@publisher.remote_digest).to be == receipt.fetch(:sha256) - expect(@publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"] && args[4].end_with?("release.tar")}).to be == 1 - expect(@publisher.releases.first.fetch("draft")).to be == false + expect(publisher.remote_digest).to be == receipt.fetch(:sha256) + expect(publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"] && args[4].end_with?("release.tar")}).to be == 1 + expect(publisher.releases.first.fetch("draft")).to be == false end it "resumes preservation from an available Actions artifact" do originals = Dir.glob(File.join(root, "pkg/*")).to_h{|file| [File.basename(file), File.binread(file)]} - @publisher.fail_preservation = true + publisher.fail_preservation = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) - @publisher.stored_files.merge!(originals) - @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] - @publisher.fail_preservation = false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) + publisher.stored_files.merge!(originals) + publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] + publisher.fail_preservation = false restore - @publisher.publish(42) + publisher.publish(42) - expect(@publisher.releases.first.fetch("assets").size).to be == 5 - expect(@publisher.remote_digest).to be == Digest::SHA256.hexdigest("Exact signed bytes") + expect(publisher.releases.first.fetch("assets").size).to be == 5 + expect(publisher.remote_digest).to be == Digest::SHA256.hexdigest("Exact signed bytes") end it "requires manual restoration if interrupted before either backup completed" do - @publisher.fail_preservation = true + publisher.fail_preservation = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Preservation failed/) expect{restore}.to raise_exception(RuntimeError, message: be =~ /incomplete/) - expect(@publisher.remote_digest).to be_nil + expect(publisher.remote_digest).to be_nil end it "rejects a corrupted backup download" do - @publisher.publish(42) - @publisher.stored_files["release.tar"] = "corrupted" + publisher.publish(42) + publisher.stored_files["release.tar"] = "corrupted" expect{restore}.to raise_exception(RuntimeError, message: be =~ /backup digest mismatch/) end it "does not overwrite conflicting local files while restoring a backup" do - @publisher.publish(42) + publisher.publish(42) File.write(File.join(root, "pkg/release.json"), "Local content") expect(ENV).to receive(:fetch).with("GITHUB_RUN_ID").and_return("123") - expect{@publisher.build(42)}.to raise_exception(RuntimeError, message: be =~ /Existing artifact differs/) + expect{publisher.build(42)}.to raise_exception(RuntimeError, message: be =~ /Existing artifact differs/) expect(File.read(File.join(root, "pkg/release.json"))).to be == "Local content" end it "does not replace an existing backup containing different bytes" do - @publisher.publish(42) + publisher.publish(42) package = File.join(root, "pkg/example-1.0.1.gem") File.write(package, "Different package") archive = File.join(root, "pkg/release.tar") Bake::Gem::GitHub::Backup.write(archive, Dir.glob(File.join(root, "pkg/*")).reject{|file| file == archive}) - @publisher.stored_files["release.tar"] = File.binread(archive) - @publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}["digest"] = "sha256:#{Digest::SHA256.file(archive).hexdigest}" + publisher.stored_files["release.tar"] = File.binread(archive) + publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}["digest"] = "sha256:#{Digest::SHA256.file(archive).hexdigest}" File.write(package, "Exact signed bytes") - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release backup differs/) + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release backup differs/) end it "restores expired workflow artifacts from the GitHub release" do - @publisher.publish(42) - @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] + publisher.publish(42) + publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] - expect(restore.fetch(:sha256)).to be == @publisher.remote_digest - @publisher.publish(42) + expect(restore.fetch(:sha256)).to be == publisher.remote_digest + publisher.publish(42) - expect(@publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 + expect(publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 end it "can still restore an available workflow artifact" do - @publisher.publish(42) - @publisher.releases = [] - @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] + publisher.publish(42) + publisher.releases = [] + publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => false}] - expect(restore.fetch(:sha256)).to be == @publisher.remote_digest + expect(restore.fetch(:sha256)).to be == publisher.remote_digest end it "refuses to rebuild when the workflow artifact expired without a release backup" do - @publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] + publisher.artifacts = [{"name" => "release-#{'a' * 40}", "expired" => true}] expect{restore}.to raise_exception(RuntimeError, message: be =~ /expired/) end it "refuses to rebuild a published version without either backup" do - @publisher.remote_digest = @publisher.load_receipt.fetch(:sha256) + publisher.remote_digest = publisher.load_receipt.fetch(:sha256) expect{restore}.to raise_exception(RuntimeError, message: be =~ /already published/) end it "builds an unpublished version and records the originating run" do - mock(@publisher) do |wrapper| + mock(publisher) do |wrapper| wrapper.replace(:build_package) do |path| file = File.join(path, "example-1.0.1.gem") File.write(file, "new gem") @@ -240,117 +227,117 @@ def restore end it "rejects an unexpected package filename before recording it" do - expect(@publisher).to receive(:build_package).and_return("different.gem") + expect(publisher).to receive(:build_package).and_return("different.gem") expect{restore}.to raise_exception(RuntimeError, message: be =~ /Unexpected package filename/) end it "requires a complete release backup before restoring" do - @publisher.publish(42) - @publisher.releases.first.fetch("assets").reject!{|asset| asset.fetch("name") == "release.tar"} - @publisher.releases.first.fetch("assets").pop + publisher.publish(42) + publisher.releases.first.fetch("assets").reject!{|asset| asset.fetch("name") == "release.tar"} + publisher.releases.first.fetch("assets").pop expect{restore}.to raise_exception(RuntimeError, message: be =~ /incomplete/) end it "rejects another source commit in the restored receipt" do - @publisher.publish(42) - @publisher.releases.first.fetch("assets").reject!{|asset| asset.fetch("name") == "release.tar"} - receipt = JSON.parse(@publisher.stored_files.fetch("release.json")) + publisher.publish(42) + publisher.releases.first.fetch("assets").reject!{|asset| asset.fetch("name") == "release.tar"} + receipt = JSON.parse(publisher.stored_files.fetch("release.json")) receipt["commit"] = "b" * 40 - @publisher.stored_files["release.json"] = JSON.generate(receipt) + publisher.stored_files["release.json"] = JSON.generate(receipt) expect{restore}.to raise_exception(RuntimeError, message: be =~ /different commit/) end it "refuses a draft release targeting another commit" do - @publisher.releases = [{"tag_name" => "v1.0.1", "draft" => true, "target_commitish" => "b" * 40}] + publisher.releases = [{"tag_name" => "v1.0.1", "draft" => true, "target_commitish" => "b" * 40}] - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /another commit/) - expect(@publisher.remote_digest).to be_nil + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /another commit/) + expect(publisher.remote_digest).to be_nil end it "refuses duplicate release records for the same tag" do - @publisher.releases = [{"tag_name" => "v1.0.1"}] * 2 + publisher.releases = [{"tag_name" => "v1.0.1"}] * 2 - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Multiple GitHub releases/) - expect(@publisher.remote_digest).to be_nil + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Multiple GitHub releases/) + expect(publisher.remote_digest).to be_nil end it "does not overwrite conflicting release assets" do - @publisher.publish(42) - @publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name").end_with?(".gem")}["digest"] = "sha256:wrong" + publisher.publish(42) + publisher.releases.first.fetch("assets").find{|asset| asset.fetch("name").end_with?(".gem")}["digest"] = "sha256:wrong" - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release asset differs/) - expect(@publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"]}).to be == 5 + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Existing release asset differs/) + expect(publisher.commands.count{|args| args[0, 3] == ["gh", "release", "upload"]}).to be == 5 end it "uses the created draft response when the release list remains stale" do - @publisher.stale_release_list = true - receipt = @publisher.publish(42) + publisher.stale_release_list = true + receipt = publisher.publish(42) - expect(@publisher.releases.size).to be == 1 - expect(@publisher.releases.first.fetch("draft")).to be == false - expect(@publisher.remote_digest).to be == receipt.fetch(:sha256) - expect(@publisher.stored_files.keys.sort).to be == ["example-1.0.1.gem", "example-1.0.1.gem.sigstore.json", "provenance.sigstore.json", "release.json", "release.tar"] + expect(publisher.releases.size).to be == 1 + expect(publisher.releases.first.fetch("draft")).to be == false + expect(publisher.remote_digest).to be == receipt.fetch(:sha256) + expect(publisher.stored_files.keys.sort).to be == ["example-1.0.1.gem", "example-1.0.1.gem.sigstore.json", "provenance.sigstore.json", "release.json", "release.tar"] end it "restores an existing draft hidden by a stale release list without creating another" do - @publisher.fail_release = true + publisher.fail_release = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) - @publisher.stale_release_list = true - @publisher.fail_release = false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) + publisher.stale_release_list = true + publisher.fail_release = false restore - @publisher.publish(42) + publisher.publish(42) - expect(@publisher.releases.size).to be == 1 - expect(@publisher.releases.first.fetch("draft")).to be == false - expect(@publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 + expect(publisher.releases.size).to be == 1 + expect(publisher.releases.first.fetch("draft")).to be == false + expect(publisher.commands.count{|args| args[0, 2] == ["gem", "push"]}).to be == 1 end it "does not interpret a failed direct lookup as an absent release" do - mock(@publisher) do |wrapper| + mock(publisher) do |wrapper| wrapper.wrap(:readlines) do |original, *arguments, **options| raise "GitHub lookup failed" if arguments[0, 3] == ["gh", "api", "graphql"] original.call(*arguments, **options) end end - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /lookup failed/) - expect(@publisher.releases).to be == [] - expect(@publisher.remote_digest).to be_nil + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /lookup failed/) + expect(publisher.releases).to be == [] + expect(publisher.remote_digest).to be_nil end it "stops before publication when required attestation verification fails" do - @publisher.fail_attestation = true + publisher.fail_attestation = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Attestation/) - expect(@publisher.remote_digest).to be_nil - expect(@publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Attestation/) + expect(publisher.remote_digest).to be_nil + expect(publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false end it "refuses a published version containing different bytes" do - @publisher.remote_digest = Digest::SHA256.hexdigest("Someone else's artifact") + publisher.remote_digest = Digest::SHA256.hexdigest("Someone else's artifact") - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /different bytes/) - expect(@publisher.commands.any?{|args| args.include?("POST")}).to be == false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /different bytes/) + expect(publisher.commands.any?{|args| args.include?("POST")}).to be == false end it "stops before publication when the receipt attestation is invalid" do - @publisher.fail_receipt_verification = true + publisher.fail_receipt_verification = true - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Receipt attestation/) - expect(@publisher.remote_digest).to be_nil - expect(@publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /Receipt attestation/) + expect(publisher.remote_digest).to be_nil + expect(publisher.commands.any?{|args| args[0, 2] == ["gem", "push"]}).to be == false end it "rejects a receipt identifying another merged source" do - path = File.join(@root, "pkg", "release.json") + path = File.join(root, "pkg", "release.json") receipt = JSON.parse(File.read(path)) receipt["commit"] = "b" * 40 File.write(path, JSON.generate(receipt)) - expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /not for this merged PR/) - expect(@publisher.commands).to be == [] + expect{publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /not for this merged PR/) + expect(publisher.commands).to be == [] end end diff --git a/test/bake/gem/github/registry.rb b/test/bake/gem/github/registry.rb index 878f993..48031d1 100644 --- a/test/bake/gem/github/registry.rb +++ b/test/bake/gem/github/registry.rb @@ -7,24 +7,57 @@ require "sus/fixtures/temporary_directory_context" describe Bake::Gem::GitHub::Registry do + let(:transport) {Object.new} + let(:http) {Object.new} + let(:registry) {subject.new(http: transport)} + let(:responses) {{}} + let(:requests) {[]} + let(:version_path) {"/api/v2/rubygems/example/versions/1.0.1.json?platform=ruby"} + let(:download_path) {"/downloads/example-1.0.1.gem"} + let(:attestation_path) {"/api/v1/attestations/example-1.0.1.json"} + let(:bytes) {"gem bytes\x00\xff".b} + + def response(code, body = "") + result = Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new("1.1", code, "") + mock(result) do |wrapper| + wrapper.replace(:body){body} + end + + return result + end + + before do + mock(http) do |wrapper| + wrapper.replace(:get) do |path| + requests << path + responses.fetch(path).shift or raise "Unexpected request: #{path}" + end + end + mock(transport) do |wrapper| + wrapper.replace(:start) do |host, port, **options, &block| + expect(host).to be == "rubygems.org" + expect(port).to be == 443 + expect(options).to be == {use_ssl: true, open_timeout: 15, read_timeout: 60} + + block.call(http) + end + end + end + with "#verify" do include Sus::Fixtures::TemporaryDirectoryContext - let(:registry) {subject.new} - let(:receipt) {{name: "example", version: "1.0.1", sha256: "expected"}} + + let(:receipt) {{name: "example", version: "1.0.1", sha256: Digest::SHA256.hexdigest(bytes)}} let(:bundle) {File.join(root, "bundle.json")} - let(:digests) {["expected"]} - let(:attestations) {[JSON.generate([{bundle: {mediaType: "test"}}])]} let(:waits) {[]} before do File.write(bundle, JSON.generate(mediaType: "test")) + responses[version_path] = [response("200", "{}")] + responses[download_path] = [response("200", bytes)] + responses[attestation_path] = [response("200", JSON.generate([{bundle: {mediaType: "test"}}]))] + mock(registry) do |wrapper| - wrapper.replace(:digest) do |*arguments| - result = digests.shift - raise result if result.is_a?(Exception) - result - end - wrapper.replace(:get){|url| attestations.shift} wrapper.replace(:sleep){|delay| waits << delay} end end @@ -33,44 +66,48 @@ def verify registry.verify(receipt, bundle, attempts: 3, delay: 10) end - it "waits for an absent version and a pending download without uploading again" do - digests.unshift(nil, Bake::Gem::GitHub::Registry::Pending.new("pending")) + it "waits for an absent version and a pending download" do + responses[version_path].unshift(response("404"), response("200", "{}")) + responses[download_path].unshift(response("404")) - expect{verify}.not.to raise_exception + expect(verify).to be_nil expect(waits).to be == [10, 10] + expect(requests.count(download_path)).to be == 2 end it "waits for an absent registry attestation" do - digests.unshift("expected") - attestations.unshift(nil) + responses[version_path].unshift(response("200", "{}")) + responses[download_path].unshift(response("200", bytes)) + responses[attestation_path].unshift(response("404")) - expect{verify}.not.to raise_exception + expect(verify).to be_nil expect(waits).to be == [10] end it "stops waiting after the bounded number of attempts" do - digests.clear + responses[version_path] = Array.new(3){response("404")} expect{verify}.to raise_exception(RuntimeError, message: be =~ /propagation did not complete/) expect(waits).to be == [10, 10] + expect(requests).to be == [version_path] * 3 end it "rejects different bytes immediately" do - digests.replace(["different"]) + responses[download_path] = [response("200", "different")] expect{verify}.to raise_exception(RuntimeError, message: be =~ /different bytes/) expect(waits).to be == [] end it "rejects an unrelated attestation immediately" do - attestations.replace([JSON.generate([{bundle: {mediaType: "unrelated"}}])]) + responses[attestation_path] = [response("200", JSON.generate([{bundle: {mediaType: "unrelated"}}]))] expect{verify}.to raise_exception(RuntimeError, message: be =~ /Sigstore bundle/) expect(waits).to be == [] end it "does not hide registry request errors" do - digests.replace([RuntimeError.new("Registry request failed: 403")]) + responses[version_path] = [response("403")] expect{verify}.to raise_exception(RuntimeError, message: be =~ /403/) expect(waits).to be == [] @@ -78,86 +115,64 @@ def verify end with "#digest" do - let(:transport) {Object.new} - let(:registry) {subject.new(http: transport)} - let(:http) {Object.new} - let(:responses) {{}} - let(:version_path) {"/api/v2/rubygems/example/versions/1.0.1.json?platform=ruby"} - let(:download_path) {"/downloads/example-1.0.1.gem"} - - def response(code, body = "") - result = Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new("1.1", code, "") - mock(result) do |wrapper| - wrapper.replace(:body){body} - end - result - end - - before do - mock(http) do |wrapper| - wrapper.replace(:get){|path| responses.fetch(path)} - end - mock(transport) do |wrapper| - wrapper.replace(:start) do |*arguments, **options, &block| - block.call(http) - end - end - end - it "recognizes an unpublished version without requesting the missing download" do - responses[version_path] = response("404") + responses[version_path] = [response("404")] expect(registry.digest("example", "1.0.1")).to be_nil + expect(requests).to be == [version_path] end it "hashes the actual published package bytes" do - responses[version_path] = response("200", "{}") - responses[download_path] = response("200", "gem bytes\x00\xff".b) + responses[version_path] = [response("200", "{}")] + responses[download_path] = [response("200", bytes)] - expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b) + expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest(bytes) end ["403", "500"].each do |code| it "rejects version API errors", unique: code do - responses[version_path] = response(code) + responses[version_path] = [response(code)] expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}") end end it "rejects a forbidden download for an existing version" do - responses[version_path] = response("200", "{}") - responses[download_path] = response("403") + responses[version_path] = [response("200", "{}")] + responses[download_path] = [response("403")] expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403") end - it "rejects a missing download for an existing version" do - responses[version_path] = response("200", "{}") - responses[download_path] = response("404") + it "reports a pending download for an existing version" do + responses[version_path] = [response("200", "{}")] + responses[download_path] = [response("404")] - expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/) + expect{registry.digest("example", "1.0.1")}.to raise_exception(Bake::Gem::GitHub::Registry::Pending) end it "follows HTTPS redirects relative to the registry URL" do - responses[version_path] = response("302") - responses[version_path]["location"] = "/version.json" - responses["/version.json"] = response("200", "{}") - responses[download_path] = response("200", "gem bytes") + redirect = response("302") + redirect["location"] = "/version.json" + responses[version_path] = [redirect] + responses["/version.json"] = [response("200", "{}")] + responses[download_path] = [response("200", bytes)] - expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes") + expect(registry.digest("example", "1.0.1")).to be == Digest::SHA256.hexdigest(bytes) end it "rejects a redirect to an unencrypted download" do - responses[version_path] = response("302") - responses[version_path]["location"] = "http://rubygems.org/version.json" + redirect = response("302") + redirect["location"] = "http://rubygems.org/version.json" + responses[version_path] = [redirect] expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /requires HTTPS/) end it "bounds registry redirect loops" do - responses[version_path] = response("302") - responses[version_path]["location"] = version_path + redirect = response("302") + redirect["location"] = version_path + responses[version_path] = [redirect] * 6 expect{registry.digest("example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Too many registry redirects/) end From 3100f64b464b2d11818d5b10fa3a8c8f746a4363 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 12:53:20 +1200 Subject: [PATCH 7/9] Organize setup, preparation, verification, and recovery guides. --- context/getting-started.md | 136 ++++++++++----------------- context/index.yaml | 18 +++- context/preparing-releases.md | 40 ++++++++ context/recovering-releases.md | 27 ++++++ context/verifying-releases.md | 43 +++++++++ guides/getting-started/readme.md | 136 ++++++++++----------------- guides/links.yaml | 8 ++ guides/preparing-releases/readme.md | 40 ++++++++ guides/recovering-releases/readme.md | 27 ++++++ guides/verifying-releases/readme.md | 43 +++++++++ readme.md | 2 +- 11 files changed, 344 insertions(+), 176 deletions(-) create mode 100644 context/preparing-releases.md create mode 100644 context/recovering-releases.md create mode 100644 context/verifying-releases.md create mode 100644 guides/links.yaml create mode 100644 guides/preparing-releases/readme.md create mode 100644 guides/recovering-releases/readme.md create mode 100644 guides/verifying-releases/readme.md diff --git a/context/getting-started.md b/context/getting-started.md index 56d1443..7e32e29 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,137 +1,101 @@ -# GitHub Releases +# Getting Started -This guide explains how to set up reviewed Ruby gem releases with `bake-gem-github`, native GitHub rules, RubyGems Trusted Publishing, and retained release artifacts. +This guide explains how to configure reviewed Ruby gem releases and prepare the first release PR with `bake-gem-github`. -## Installation +## How releases work -Add `bake-gem-github` and `agent-context` to your maintenance bundle. This companion requires `bake-gem` 0.15 or later for branch preparation and regeneration validation. Install the maintenance group in CI with `BUNDLE_WITH=maintenance`. +Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging; after merge, GitHub Actions builds the exact merged commit and publishes its verified artifact to RubyGems. -Use one gemspec, a stable three-part version in `lib/.../version.rb`, and repeatable `after_gem_release_version_increment` hooks. Hooks run from a clean base during validation. Commit dependency locks when practical; changing generation tools or using live network/time inputs can make old release content fail validation. +`bake-gem` provides version updates, release hooks, and clean builds. `bake-gem-github` adds PR preparation, GitHub policy, and remote publishing. The supported process uses one gemspec, stable three-part versions, merge or squash merging, and RubyGems.org. -## Setup and migration +## Installation -Run setup in each repository. It discovers the canonical repository and default branch through `gh` and generates reviewable local files. Supply the actual required CI job names, including supported matrix entries: +Add these dependencies to the maintenance group in `gems.rb`: -``` bash -bundle exec bake gem:github:setup checks="3.3 on ubuntu,3.3 on macos,3.4 on ubuntu,3.4 on macos,4.0 on ubuntu,4.0 on macos,check,ruby on ubuntu,ruby on macos,validate" -bundle exec bake agent:context:install -bundle exec bake gem:github:setup:plan +``` ruby +group :maintenance, optional: true do + gem "bake-gem-github" + gem "agent-context" +end ``` -This example uses the job names from the standard `bake modernize` test, RuboCop, and coverage workflows. Select the checks actually produced by your repository; experimental Ruby jobs are not required. When changing workflow job names, update `config/release.yaml` and apply the corresponding rulesets so required checks keep matching the workflows. - -Setup adds three release workflows, `config/release.yaml`, and native ruleset payloads. Identical reruns do nothing; conflicting existing files stop before any file is written. Setup does not replace other publishers: remove conflicting release workflows during migration. - -To adopt template fixes after upgrading the gem, start from a clean working tree, edit `config/release.yaml` as needed, and regenerate: +Install that group and its guidance: ``` bash -bundle exec bake gem:github:setup:update -git diff +bundle config set --local with maintenance +bundle install +bundle exec bake agent:context:install ``` -The task updates the managed workflows, policy payloads, and configuration formatting directly in the working tree and returns the changed paths. It does not stage, commit, or change remote settings. An agent or maintainer can review the diff and selectively keep changes, restoring repository-specific customizations from Git where needed. Commit or stash existing edits first: generated files are replaced by the current templates. Repeating an update produces no further changes; intentionally retained customizations will appear in later update diffs. Apply remote rulesets after the corresponding workflows are running. +The companion requires `bake-gem` 0.15 or later. Its generated release workflows install maintenance dependencies with `BUNDLE_WITH=maintenance`. -Release workflows follow `bake modernize` action versions and use moving major tags where upstream provides them. These tags receive upstream updates automatically; full commit hashes select fixed revisions. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended), since upstream does not provide a moving major tag. Repositories that require fixed revisions can customize these references. +Use a version constant in `lib/.../version.rb` and repeatable `after_gem_release_version_increment` hooks. Validation invokes these hooks from a clean base. Generation must not depend on changing network responses or the current time; review dependency updates that could alter generated content. -The default is two approvals with explicit administrator bypass, dismissed stale reviews, approval of the last push, strict up-to-date CI, and immutable default-branch history/release tags. These branch rules affect **all PRs** into the default branch. Ordinary administrator reviews count as one review. A human who dispatches a bot-authored PR is not its author under GitHub's native rules. +## Generate repository configuration -Review `gem:github:setup:plan`, merge the setup PR, and confirm that **Release validation** and every selected check run. Then apply the four managed rulesets using an administrator's `gh` login: +Run setup from the repository root with the actual required CI job names, including supported matrix entries: ``` bash -bundle exec bake gem:github:setup:apply +bundle exec bake gem:github:setup checks="3.3 on ubuntu,3.3 on macos,3.4 on ubuntu,3.4 on macos,4.0 on ubuntu,4.0 on macos,check,ruby on ubuntu,ruby on macos,validate" +bundle exec bake gem:github:setup:plan ``` -This command changes remote rulesets and preserves unrelated rulesets. Existing rulesets with the four managed names are updated. Organization rules and other existing protections still apply. Check names in configuration must exactly match GitHub checks; a partial selection does not mean all CI is required. Keep rebase merging and merge queues disabled for the initial rollout. +This example follows the standard `bake modernize` test, RuboCop, and coverage job names. Select the jobs produced by your repository. Experimental Ruby jobs are not required. Setup adds `Release validation` automatically. -Create a `rubygems` GitHub environment restricted to the default branch. Do not add a second routine reviewer gate. On RubyGems, an owner must configure a Trusted Publisher with the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`** shown by `doctor`. Ownership/MFA and environment/signing bootstrap are deliberate manual steps in this first implementation; `doctor` prints desired and observed GitHub settings, not a claim that RubyGems ownership or publisher trust has been verified. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/). +Setup discovers the canonical GitHub repository and default branch through `gh`. It generates three release workflows, `config/release.yaml`, and four native ruleset payloads. Identical reruns do nothing; conflicting existing files stop generation before any file is written. Review and commit the files in a setup PR, and remove conflicting publishing workflows. -If `release.cert` exists, setup enables legacy signing. Keep this public certificate in Git and install its matching **private key** as the Actions secret `GEM_SIGNING_KEY`. Use the `rubygems` environment or an organization secret available to the release repositories. The workflow checks the certificate validity, key match, and resulting package signatures. To opt out explicitly, pass `signing=false` during setup. No long-lived RubyGems publishing key is required. +The rules require two approvals by default, allow explicit administrator bypass, dismiss stale reviews, require approval of the last push, and require up-to-date CI. They protect default-branch history and release tags against deletion or replacement. These branch rules apply to **all PRs** into the default branch. An ordinary administrator approval counts as one review; bypass is a separate action. -Before enabling releases, confirm two people can administer the repository and recover the RubyGems account/signing key, and enough maintainers can satisfy the review policy. Pilot on one low-risk gem and prove publishing, administrator bypass, fork merges, and recovery before rolling out broadly. No organization-wide migration or live publisher setup is performed by these tasks. +## Configure publishing credentials -## Request and review +Create a `rubygems` GitHub environment restricted to the default branch. On RubyGems, an owner must configure a Trusted Publisher with the values printed by `gem:github:setup:plan`: the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`**. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for the account setup. -``` bash -# Local branch and commit only: -bundle exec bake gem:release:branch:patch - -# From the current default branch: prepare, validate, push and open PR: -bundle exec bake gem:github:release:patch +Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports expected RubyGems values; it does not verify ownership or publisher trust. Trusted Publishing supplies the publishing credential for each run, so a long-lived RubyGems API key is unnecessary. -# Remote request (also available in the Actions UI): -gh workflow run release-prepare.yaml -f bump=patch -``` +When `release.cert` exists, setup enables certificate signing. Commit the public certificate and install its matching private key as `GEM_SIGNING_KEY`, either in the `rubygems` environment or as an organization secret available to the repository. The publisher checks certificate validity, key matching, and package signatures. Use `signing=false` during setup to disable certificate signing. -Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. +Ensure another maintainer can administer the repository and recover its RubyGems account and signing key. Keep the native PR review policy as the routine approval step; the environment does not need another reviewer gate. -All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. +## Enable the policy -If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. - -When validation reports stale content, explicitly refresh the same release: +Merge the setup PR and confirm every selected CI job, including **Release validation**, runs. Review the plan again, then apply its rules using an administrator's `gh` login: ``` bash -git switch main -git pull --ff-only -bundle exec bake gem:github:release:patch refresh=true -# Or dispatch remotely: -gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +bundle exec bake gem:github:setup:plan +bundle exec bake gem:github:setup:apply ``` -Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. +Apply updates only the four managed rulesets and preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process. -## Publish and verify +## Prepare the first release PR -After merge/squash, the publishing workflow verifies GitHub's merged PR record and ancestry, then checks out the exact merged commit. Later development on the default branch is allowed. It regenerates against the merged commit's **first parent**, builds in a clean worktree, optionally certificate-signs, and creates two attestations over the final bytes: - -- A Sigstore bundle submitted explicitly with `gem push --attestation` using RubyGems 4.0.21. -- GitHub's native SLSA provenance covering both the gem and `release.json`. This signed receipt binds the gem digest to the exact release commit, even when the workflow's own default-branch revision is newer. - -The workflow retains the gem, receipt and attestations before obtaining RubyGems publishing credentials. It verifies both attestations, checks the uploaded bytes and registry bundle, then pushes the specific version tag and creates the GitHub release. Existing tags/assets are checked and never overwritten. The old `after_gem_release` GitHub hook is not called by this pipeline, so there is one owner for release creation. - -The draft release description includes the exact version's notes from `releases.md` in the merged release checkout, followed by the PR URL, source commit, and gem digest. Notes are extracted using `bake-releases`; a missing or empty section leaves the metadata as the description. Retries preserve the existing release description. +From an up-to-date default branch: ``` bash -set -e -for file in example-1.2.3.gem release.json; do - gh attestation verify "$file" \ - --repo OWNER/REPOSITORY --bundle provenance.sigstore.json \ - --cert-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ - --source-ref refs/heads/main --deny-self-hosted-runners -done - -jq -e --arg commit MERGED_SHA \ - --arg digest "$(shasum -a 256 example-1.2.3.gem | cut -d ' ' -f1)" \ - '.commit == $commit and .sha256 == $digest' release.json - -gem exec sigstore-cli:0.2.3 verify example-1.2.3.gem \ - --bundle example-1.2.3.gem.sigstore.json \ - --certificate-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com +bundle exec bake gem:github:release:patch ``` -Download `release.json` and `provenance.sigstore.json` alongside the gem. Verify both subjects before reading the receipt's source commit and digest. GitHub CLI's `--source-digest` checks the workflow revision, which may differ from the release commit recorded in the signed receipt. Replace `main` with the configured default branch in these commands. +The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release. -Native GitHub records the merge and any bypass; the signed artifact receipt includes the PR and merging actor. This version does not export organization audit-log evidence or infer bypass reasons from review counts. +See [Preparing Releases](../preparing-releases/index) for remote requests and stale-content refresh, [Verifying Releases](../verifying-releases/index) for artifact checks, and [Recovering Releases](../recovering-releases/index) when a workflow stops partway through. -## Recovery +## Update generated files -Use **Re-run all jobs** on the original publishing run, or: +After upgrading the gem, start from a clean working tree and regenerate using your existing configuration: ``` bash -bundle exec bake gem:github:release:resume run=RUN_ID +bundle exec bake gem:github:setup:update +git diff ``` -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. - -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. +This updates managed files in the working tree and returns their changed paths. Review the diff and selectively retain repository customizations before committing. The task does not stage, commit, or change remote settings. Repeated updates produce no further changes unless customizations differ from the templates. Apply changed rulesets after the corresponding workflows are running. -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. +The release workflows follow `bake modernize` action versions and use moving major tags where available. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended). Repositories that require fixed revisions can customize these references. -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. +## Current scope -## Development and current limits +The process has published `bake-gem-github` through GitHub Actions. Each adopting repository still needs its own reviewed setup and successful release. Public single-gem repositories, ordinary stable versions, merge/squash, GitHub-hosted Linux runners, and RubyGems.org are the supported starting point. -The implementation has local repository and transport-fake tests. A real GitHub/RubyGems pilot remains necessary before enabling it across Socketry. Public single-gem repositories, ordinary stable versions, merge/squash, GitHub-hosted Linux runners, and RubyGems.org are the supported starting point. Native build matrices, reusable publisher workflows, merge queues, automated RubyGems ownership/MFA setup, cross-run artifact recovery, and organization-wide rollout are deferred. +Native build matrices, reusable publisher workflows, merge queues, automated RubyGems ownership/MFA setup, cross-run artifact recovery, and organization-wide migration are outside the current setup tasks. -Edit this guide and regenerate `context/` with `bake utopia:project:agent:context:update`. Consumers install the generated guidance using `agent-context`. +Edit source guides under `guides/` and regenerate the distributed guidance with `bundle exec bake utopia:project:agent:context:update`. Consumers install it through `agent-context`. diff --git a/context/index.yaml b/context/index.yaml index 96daace..399ec92 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -8,6 +8,18 @@ metadata: source_code_uri: https://github.com/socketry/bake-gem-github.git files: - path: getting-started.md - title: GitHub Releases - description: This guide explains how to set up reviewed Ruby gem releases with `bake-gem-github`, - native GitHub rules, RubyGems Trusted Publishing, and retained release artifacts. + title: Getting Started + description: This guide explains how to configure reviewed Ruby gem releases and + prepare the first release PR with `bake-gem-github`. +- path: preparing-releases.md + title: Preparing Releases + description: This guide explains how to request a release PR, resume interrupted + preparation, and refresh generated content when the default branch changes. +- path: verifying-releases.md + title: Verifying Releases + description: This guide explains how publishing binds a gem to its reviewed source + and how to verify the downloaded artifact and attestations. +- path: recovering-releases.md + title: Recovering Releases + description: This guide explains how to resume an interrupted publishing workflow + using the original gem and its verification evidence. diff --git a/context/preparing-releases.md b/context/preparing-releases.md new file mode 100644 index 0000000..fface6c --- /dev/null +++ b/context/preparing-releases.md @@ -0,0 +1,40 @@ +# Preparing Releases + +This guide explains how to request a release PR, resume interrupted preparation, and refresh generated content when the default branch changes. + +Complete [Getting Started](../getting-started/index) first. Release preparation uses {ruby Bake::Gem::GitHub::Project#prepare} to coordinate the core Bake tasks and GitHub operations. Run local tasks from the repository root so gemspec paths resolve correctly. + +## Request a release + +``` bash +# Local branch and commit only: +bundle exec bake gem:release:branch:patch + +# From the current default branch: prepare, validate, push and open PR: +bundle exec bake gem:github:release:patch + +# Remote request (also available in the Actions UI): +gh workflow run release-prepare.yaml -f bump=patch +``` + +Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. + +All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. + +## Resume interrupted preparation + +If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. + +## Refresh stale content + +When new changes alter the generated release notes or other artifacts, rebasing the branch alone does not regenerate them. Validation reports the stale content. Explicitly refresh the same release: + +``` bash +git switch main +git pull --ff-only +bundle exec bake gem:github:release:patch refresh=true +# Or dispatch remotely: +gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +``` + +Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. diff --git a/context/recovering-releases.md b/context/recovering-releases.md new file mode 100644 index 0000000..5f43834 --- /dev/null +++ b/context/recovering-releases.md @@ -0,0 +1,27 @@ +# Recovering Releases + +This guide explains how to resume an interrupted publishing workflow using the original gem and its verification evidence. + +Use this when a workflow fails during preservation, upload, registry propagation, or tag/release finalization. For failures while creating the PR, see [Preparing Releases](../preparing-releases/index). Publishing recovery retains the original source and artifact bytes. + +## Rerun the original workflow + +Use **Re-run all jobs** on the original publishing run, or: + +``` bash +bundle exec bake gem:github:release:resume run=RUN_ID +``` + +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. + +## Restore retained artifacts + +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. + +## Handle incomplete preservation + +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. + +## Understand workflow reruns + +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. diff --git a/context/verifying-releases.md b/context/verifying-releases.md new file mode 100644 index 0000000..a4228db --- /dev/null +++ b/context/verifying-releases.md @@ -0,0 +1,43 @@ +# Verifying Releases + +This guide explains how publishing binds a gem to its reviewed source and how to verify the downloaded artifact and attestations. + +Use this when checking a completed release or confirming which source commit produced a package. [Getting Started](../getting-started/index) describes publisher configuration; [Recovering Releases](../recovering-releases/index) covers interrupted workflows. + +## What publishing verifies + +After merge/squash, the publishing workflow verifies GitHub's merged PR record and ancestry, then checks out the exact merged commit. Later development on the default branch is allowed. It regenerates against the merged commit's **first parent**, builds in a clean worktree, optionally certificate-signs, and creates two attestations over the final bytes: + +- A Sigstore bundle submitted explicitly with `gem push --attestation` using RubyGems 4.0.21. +- GitHub's native SLSA provenance covering both the gem and `release.json`. This signed receipt binds the gem digest to the exact release commit, even when the workflow's own default-branch revision is newer. + +The workflow retains the gem, receipt and attestations before obtaining RubyGems publishing credentials. It verifies both attestations, checks the uploaded bytes and registry bundle, then pushes the specific version tag and creates the GitHub release. Existing tags/assets are checked and never overwritten. The old `after_gem_release` GitHub hook is not called by this pipeline, so there is one owner for release creation. + +The draft release description includes the exact version's notes from `releases.md` in the merged release checkout, followed by the PR URL, source commit, and gem digest. Notes are extracted using `bake-releases`; a missing or empty section leaves the metadata as the description. Retries preserve the existing release description. + +## Verify downloaded artifacts + +Download the gem, its `.sigstore.json` bundle, `release.json`, and `provenance.sigstore.json` from the GitHub release. Replace the example package, owner/repository, and `MERGED_SHA` below with the release being checked. Run from the directory containing those files: + +``` bash +set -e +for file in example-1.2.3.gem release.json; do + gh attestation verify "$file" \ + --repo OWNER/REPOSITORY --bundle provenance.sigstore.json \ + --cert-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ + --source-ref refs/heads/main --deny-self-hosted-runners +done + +jq -e --arg commit MERGED_SHA \ + --arg digest "$(shasum -a 256 example-1.2.3.gem | cut -d ' ' -f1)" \ + '.commit == $commit and .sha256 == $digest' release.json + +gem exec sigstore-cli:0.2.3 verify example-1.2.3.gem \ + --bundle example-1.2.3.gem.sigstore.json \ + --certificate-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +Download `release.json` and `provenance.sigstore.json` alongside the gem. Verify both subjects before reading the receipt's source commit and digest. GitHub CLI's `--source-digest` checks the workflow revision, which may differ from the release commit recorded in the signed receipt. Replace `main` with the configured default branch in these commands. + +Native GitHub records the merge and any bypass; the signed artifact receipt includes the PR and merging actor. This version does not export organization audit-log evidence or infer bypass reasons from review counts. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 56d1443..7e32e29 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,137 +1,101 @@ -# GitHub Releases +# Getting Started -This guide explains how to set up reviewed Ruby gem releases with `bake-gem-github`, native GitHub rules, RubyGems Trusted Publishing, and retained release artifacts. +This guide explains how to configure reviewed Ruby gem releases and prepare the first release PR with `bake-gem-github`. -## Installation +## How releases work -Add `bake-gem-github` and `agent-context` to your maintenance bundle. This companion requires `bake-gem` 0.15 or later for branch preparation and regeneration validation. Install the maintenance group in CI with `BUNDLE_WITH=maintenance`. +Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging; after merge, GitHub Actions builds the exact merged commit and publishes its verified artifact to RubyGems. -Use one gemspec, a stable three-part version in `lib/.../version.rb`, and repeatable `after_gem_release_version_increment` hooks. Hooks run from a clean base during validation. Commit dependency locks when practical; changing generation tools or using live network/time inputs can make old release content fail validation. +`bake-gem` provides version updates, release hooks, and clean builds. `bake-gem-github` adds PR preparation, GitHub policy, and remote publishing. The supported process uses one gemspec, stable three-part versions, merge or squash merging, and RubyGems.org. -## Setup and migration +## Installation -Run setup in each repository. It discovers the canonical repository and default branch through `gh` and generates reviewable local files. Supply the actual required CI job names, including supported matrix entries: +Add these dependencies to the maintenance group in `gems.rb`: -``` bash -bundle exec bake gem:github:setup checks="3.3 on ubuntu,3.3 on macos,3.4 on ubuntu,3.4 on macos,4.0 on ubuntu,4.0 on macos,check,ruby on ubuntu,ruby on macos,validate" -bundle exec bake agent:context:install -bundle exec bake gem:github:setup:plan +``` ruby +group :maintenance, optional: true do + gem "bake-gem-github" + gem "agent-context" +end ``` -This example uses the job names from the standard `bake modernize` test, RuboCop, and coverage workflows. Select the checks actually produced by your repository; experimental Ruby jobs are not required. When changing workflow job names, update `config/release.yaml` and apply the corresponding rulesets so required checks keep matching the workflows. - -Setup adds three release workflows, `config/release.yaml`, and native ruleset payloads. Identical reruns do nothing; conflicting existing files stop before any file is written. Setup does not replace other publishers: remove conflicting release workflows during migration. - -To adopt template fixes after upgrading the gem, start from a clean working tree, edit `config/release.yaml` as needed, and regenerate: +Install that group and its guidance: ``` bash -bundle exec bake gem:github:setup:update -git diff +bundle config set --local with maintenance +bundle install +bundle exec bake agent:context:install ``` -The task updates the managed workflows, policy payloads, and configuration formatting directly in the working tree and returns the changed paths. It does not stage, commit, or change remote settings. An agent or maintainer can review the diff and selectively keep changes, restoring repository-specific customizations from Git where needed. Commit or stash existing edits first: generated files are replaced by the current templates. Repeating an update produces no further changes; intentionally retained customizations will appear in later update diffs. Apply remote rulesets after the corresponding workflows are running. +The companion requires `bake-gem` 0.15 or later. Its generated release workflows install maintenance dependencies with `BUNDLE_WITH=maintenance`. -Release workflows follow `bake modernize` action versions and use moving major tags where upstream provides them. These tags receive upstream updates automatically; full commit hashes select fixed revisions. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended), since upstream does not provide a moving major tag. Repositories that require fixed revisions can customize these references. +Use a version constant in `lib/.../version.rb` and repeatable `after_gem_release_version_increment` hooks. Validation invokes these hooks from a clean base. Generation must not depend on changing network responses or the current time; review dependency updates that could alter generated content. -The default is two approvals with explicit administrator bypass, dismissed stale reviews, approval of the last push, strict up-to-date CI, and immutable default-branch history/release tags. These branch rules affect **all PRs** into the default branch. Ordinary administrator reviews count as one review. A human who dispatches a bot-authored PR is not its author under GitHub's native rules. +## Generate repository configuration -Review `gem:github:setup:plan`, merge the setup PR, and confirm that **Release validation** and every selected check run. Then apply the four managed rulesets using an administrator's `gh` login: +Run setup from the repository root with the actual required CI job names, including supported matrix entries: ``` bash -bundle exec bake gem:github:setup:apply +bundle exec bake gem:github:setup checks="3.3 on ubuntu,3.3 on macos,3.4 on ubuntu,3.4 on macos,4.0 on ubuntu,4.0 on macos,check,ruby on ubuntu,ruby on macos,validate" +bundle exec bake gem:github:setup:plan ``` -This command changes remote rulesets and preserves unrelated rulesets. Existing rulesets with the four managed names are updated. Organization rules and other existing protections still apply. Check names in configuration must exactly match GitHub checks; a partial selection does not mean all CI is required. Keep rebase merging and merge queues disabled for the initial rollout. +This example follows the standard `bake modernize` test, RuboCop, and coverage job names. Select the jobs produced by your repository. Experimental Ruby jobs are not required. Setup adds `Release validation` automatically. -Create a `rubygems` GitHub environment restricted to the default branch. Do not add a second routine reviewer gate. On RubyGems, an owner must configure a Trusted Publisher with the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`** shown by `doctor`. Ownership/MFA and environment/signing bootstrap are deliberate manual steps in this first implementation; `doctor` prints desired and observed GitHub settings, not a claim that RubyGems ownership or publisher trust has been verified. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/). +Setup discovers the canonical GitHub repository and default branch through `gh`. It generates three release workflows, `config/release.yaml`, and four native ruleset payloads. Identical reruns do nothing; conflicting existing files stop generation before any file is written. Review and commit the files in a setup PR, and remove conflicting publishing workflows. -If `release.cert` exists, setup enables legacy signing. Keep this public certificate in Git and install its matching **private key** as the Actions secret `GEM_SIGNING_KEY`. Use the `rubygems` environment or an organization secret available to the release repositories. The workflow checks the certificate validity, key match, and resulting package signatures. To opt out explicitly, pass `signing=false` during setup. No long-lived RubyGems publishing key is required. +The rules require two approvals by default, allow explicit administrator bypass, dismiss stale reviews, require approval of the last push, and require up-to-date CI. They protect default-branch history and release tags against deletion or replacement. These branch rules apply to **all PRs** into the default branch. An ordinary administrator approval counts as one review; bypass is a separate action. -Before enabling releases, confirm two people can administer the repository and recover the RubyGems account/signing key, and enough maintainers can satisfy the review policy. Pilot on one low-risk gem and prove publishing, administrator bypass, fork merges, and recovery before rolling out broadly. No organization-wide migration or live publisher setup is performed by these tasks. +## Configure publishing credentials -## Request and review +Create a `rubygems` GitHub environment restricted to the default branch. On RubyGems, an owner must configure a Trusted Publisher with the values printed by `gem:github:setup:plan`: the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`**. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for the account setup. -``` bash -# Local branch and commit only: -bundle exec bake gem:release:branch:patch - -# From the current default branch: prepare, validate, push and open PR: -bundle exec bake gem:github:release:patch +Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports expected RubyGems values; it does not verify ownership or publisher trust. Trusted Publishing supplies the publishing credential for each run, so a long-lived RubyGems API key is unnecessary. -# Remote request (also available in the Actions UI): -gh workflow run release-prepare.yaml -f bump=patch -``` +When `release.cert` exists, setup enables certificate signing. Commit the public certificate and install its matching private key as `GEM_SIGNING_KEY`, either in the `rubygems` environment or as an organization secret available to the repository. The publisher checks certificate validity, key matching, and package signatures. Use `signing=false` during setup to disable certificate signing. -Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. +Ensure another maintainer can administer the repository and recover its RubyGems account and signing key. Keep the native PR review policy as the routine approval step; the environment does not need another reviewer gate. -All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. +## Enable the policy -If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. - -When validation reports stale content, explicitly refresh the same release: +Merge the setup PR and confirm every selected CI job, including **Release validation**, runs. Review the plan again, then apply its rules using an administrator's `gh` login: ``` bash -git switch main -git pull --ff-only -bundle exec bake gem:github:release:patch refresh=true -# Or dispatch remotely: -gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +bundle exec bake gem:github:setup:plan +bundle exec bake gem:github:setup:apply ``` -Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. +Apply updates only the four managed rulesets and preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process. -## Publish and verify +## Prepare the first release PR -After merge/squash, the publishing workflow verifies GitHub's merged PR record and ancestry, then checks out the exact merged commit. Later development on the default branch is allowed. It regenerates against the merged commit's **first parent**, builds in a clean worktree, optionally certificate-signs, and creates two attestations over the final bytes: - -- A Sigstore bundle submitted explicitly with `gem push --attestation` using RubyGems 4.0.21. -- GitHub's native SLSA provenance covering both the gem and `release.json`. This signed receipt binds the gem digest to the exact release commit, even when the workflow's own default-branch revision is newer. - -The workflow retains the gem, receipt and attestations before obtaining RubyGems publishing credentials. It verifies both attestations, checks the uploaded bytes and registry bundle, then pushes the specific version tag and creates the GitHub release. Existing tags/assets are checked and never overwritten. The old `after_gem_release` GitHub hook is not called by this pipeline, so there is one owner for release creation. - -The draft release description includes the exact version's notes from `releases.md` in the merged release checkout, followed by the PR URL, source commit, and gem digest. Notes are extracted using `bake-releases`; a missing or empty section leaves the metadata as the description. Retries preserve the existing release description. +From an up-to-date default branch: ``` bash -set -e -for file in example-1.2.3.gem release.json; do - gh attestation verify "$file" \ - --repo OWNER/REPOSITORY --bundle provenance.sigstore.json \ - --cert-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ - --source-ref refs/heads/main --deny-self-hosted-runners -done - -jq -e --arg commit MERGED_SHA \ - --arg digest "$(shasum -a 256 example-1.2.3.gem | cut -d ' ' -f1)" \ - '.commit == $commit and .sha256 == $digest' release.json - -gem exec sigstore-cli:0.2.3 verify example-1.2.3.gem \ - --bundle example-1.2.3.gem.sigstore.json \ - --certificate-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com +bundle exec bake gem:github:release:patch ``` -Download `release.json` and `provenance.sigstore.json` alongside the gem. Verify both subjects before reading the receipt's source commit and digest. GitHub CLI's `--source-digest` checks the workflow revision, which may differ from the release commit recorded in the signed receipt. Replace `main` with the configured default branch in these commands. +The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release. -Native GitHub records the merge and any bypass; the signed artifact receipt includes the PR and merging actor. This version does not export organization audit-log evidence or infer bypass reasons from review counts. +See [Preparing Releases](../preparing-releases/index) for remote requests and stale-content refresh, [Verifying Releases](../verifying-releases/index) for artifact checks, and [Recovering Releases](../recovering-releases/index) when a workflow stops partway through. -## Recovery +## Update generated files -Use **Re-run all jobs** on the original publishing run, or: +After upgrading the gem, start from a clean working tree and regenerate using your existing configuration: ``` bash -bundle exec bake gem:github:release:resume run=RUN_ID +bundle exec bake gem:github:setup:update +git diff ``` -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. - -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. +This updates managed files in the working tree and returns their changed paths. Review the diff and selectively retain repository customizations before committing. The task does not stage, commit, or change remote settings. Repeated updates produce no further changes unless customizations differ from the templates. Apply changed rulesets after the corresponding workflows are running. -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. +The release workflows follow `bake modernize` action versions and use moving major tags where available. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended). Repositories that require fixed revisions can customize these references. -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. +## Current scope -## Development and current limits +The process has published `bake-gem-github` through GitHub Actions. Each adopting repository still needs its own reviewed setup and successful release. Public single-gem repositories, ordinary stable versions, merge/squash, GitHub-hosted Linux runners, and RubyGems.org are the supported starting point. -The implementation has local repository and transport-fake tests. A real GitHub/RubyGems pilot remains necessary before enabling it across Socketry. Public single-gem repositories, ordinary stable versions, merge/squash, GitHub-hosted Linux runners, and RubyGems.org are the supported starting point. Native build matrices, reusable publisher workflows, merge queues, automated RubyGems ownership/MFA setup, cross-run artifact recovery, and organization-wide rollout are deferred. +Native build matrices, reusable publisher workflows, merge queues, automated RubyGems ownership/MFA setup, cross-run artifact recovery, and organization-wide migration are outside the current setup tasks. -Edit this guide and regenerate `context/` with `bake utopia:project:agent:context:update`. Consumers install the generated guidance using `agent-context`. +Edit source guides under `guides/` and regenerate the distributed guidance with `bundle exec bake utopia:project:agent:context:update`. Consumers install it through `agent-context`. diff --git a/guides/links.yaml b/guides/links.yaml new file mode 100644 index 0000000..cab9fa8 --- /dev/null +++ b/guides/links.yaml @@ -0,0 +1,8 @@ +getting-started: + order: 1 +preparing-releases: + order: 2 +verifying-releases: + order: 3 +recovering-releases: + order: 4 diff --git a/guides/preparing-releases/readme.md b/guides/preparing-releases/readme.md new file mode 100644 index 0000000..fface6c --- /dev/null +++ b/guides/preparing-releases/readme.md @@ -0,0 +1,40 @@ +# Preparing Releases + +This guide explains how to request a release PR, resume interrupted preparation, and refresh generated content when the default branch changes. + +Complete [Getting Started](../getting-started/index) first. Release preparation uses {ruby Bake::Gem::GitHub::Project#prepare} to coordinate the core Bake tasks and GitHub operations. Run local tasks from the repository root so gemspec paths resolve correctly. + +## Request a release + +``` bash +# Local branch and commit only: +bundle exec bake gem:release:branch:patch + +# From the current default branch: prepare, validate, push and open PR: +bundle exec bake gem:github:release:patch + +# Remote request (also available in the Actions UI): +gh workflow run release-prepare.yaml -f bump=patch +``` + +Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. + +All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. + +## Resume interrupted preparation + +If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. + +## Refresh stale content + +When new changes alter the generated release notes or other artifacts, rebasing the branch alone does not regenerate them. Validation reports the stale content. Explicitly refresh the same release: + +``` bash +git switch main +git pull --ff-only +bundle exec bake gem:github:release:patch refresh=true +# Or dispatch remotely: +gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +``` + +Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. diff --git a/guides/recovering-releases/readme.md b/guides/recovering-releases/readme.md new file mode 100644 index 0000000..5f43834 --- /dev/null +++ b/guides/recovering-releases/readme.md @@ -0,0 +1,27 @@ +# Recovering Releases + +This guide explains how to resume an interrupted publishing workflow using the original gem and its verification evidence. + +Use this when a workflow fails during preservation, upload, registry propagation, or tag/release finalization. For failures while creating the PR, see [Preparing Releases](../preparing-releases/index). Publishing recovery retains the original source and artifact bytes. + +## Rerun the original workflow + +Use **Re-run all jobs** on the original publishing run, or: + +``` bash +bundle exec bake gem:github:release:resume run=RUN_ID +``` + +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. + +## Restore retained artifacts + +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. + +## Handle incomplete preservation + +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. + +## Understand workflow reruns + +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. diff --git a/guides/verifying-releases/readme.md b/guides/verifying-releases/readme.md new file mode 100644 index 0000000..a4228db --- /dev/null +++ b/guides/verifying-releases/readme.md @@ -0,0 +1,43 @@ +# Verifying Releases + +This guide explains how publishing binds a gem to its reviewed source and how to verify the downloaded artifact and attestations. + +Use this when checking a completed release or confirming which source commit produced a package. [Getting Started](../getting-started/index) describes publisher configuration; [Recovering Releases](../recovering-releases/index) covers interrupted workflows. + +## What publishing verifies + +After merge/squash, the publishing workflow verifies GitHub's merged PR record and ancestry, then checks out the exact merged commit. Later development on the default branch is allowed. It regenerates against the merged commit's **first parent**, builds in a clean worktree, optionally certificate-signs, and creates two attestations over the final bytes: + +- A Sigstore bundle submitted explicitly with `gem push --attestation` using RubyGems 4.0.21. +- GitHub's native SLSA provenance covering both the gem and `release.json`. This signed receipt binds the gem digest to the exact release commit, even when the workflow's own default-branch revision is newer. + +The workflow retains the gem, receipt and attestations before obtaining RubyGems publishing credentials. It verifies both attestations, checks the uploaded bytes and registry bundle, then pushes the specific version tag and creates the GitHub release. Existing tags/assets are checked and never overwritten. The old `after_gem_release` GitHub hook is not called by this pipeline, so there is one owner for release creation. + +The draft release description includes the exact version's notes from `releases.md` in the merged release checkout, followed by the PR URL, source commit, and gem digest. Notes are extracted using `bake-releases`; a missing or empty section leaves the metadata as the description. Retries preserve the existing release description. + +## Verify downloaded artifacts + +Download the gem, its `.sigstore.json` bundle, `release.json`, and `provenance.sigstore.json` from the GitHub release. Replace the example package, owner/repository, and `MERGED_SHA` below with the release being checked. Run from the directory containing those files: + +``` bash +set -e +for file in example-1.2.3.gem release.json; do + gh attestation verify "$file" \ + --repo OWNER/REPOSITORY --bundle provenance.sigstore.json \ + --cert-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ + --source-ref refs/heads/main --deny-self-hosted-runners +done + +jq -e --arg commit MERGED_SHA \ + --arg digest "$(shasum -a 256 example-1.2.3.gem | cut -d ' ' -f1)" \ + '.commit == $commit and .sha256 == $digest' release.json + +gem exec sigstore-cli:0.2.3 verify example-1.2.3.gem \ + --bundle example-1.2.3.gem.sigstore.json \ + --certificate-identity https://github.com/OWNER/REPOSITORY/.github/workflows/release-publish.yaml@refs/heads/main \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +Download `release.json` and `provenance.sigstore.json` alongside the gem. Verify both subjects before reading the receipt's source commit and digest. GitHub CLI's `--source-digest` checks the workflow revision, which may differ from the release commit recorded in the signed receipt. Replace `main` with the configured default branch in these commands. + +Native GitHub records the merge and any bypass; the signed artifact receipt includes the PR and merging actor. This version does not export organization audit-log evidence or infer bypass reasons from review counts. diff --git a/readme.md b/readme.md index 2764242..939c6c1 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ Reviewed GitHub releases for Ruby gems, using `bake-gem` for branch preparation, - `gem:github:setup:plan` / `apply`: inspect and apply the managed GitHub rulesets. - `gem:github:release:resume run=ID`: retry with the original artifact. -Read [the setup, release and recovery guide](https://github.com/socketry/bake-gem-github/blob/main/guides/getting-started/readme.md) before enabling publishing. Context is distributed through `agent-context`. This initial implementation requires `bake-gem` 0.15 or later and a live pilot before wider rollout. +Read [the setup, release and recovery guide](https://github.com/socketry/bake-gem-github/blob/main/guides/getting-started/readme.md) before enabling publishing. Context is distributed through `agent-context`. Requires `bake-gem` 0.15 or later. ## Making Releases From 9baee40e0115595b6b47473ceb9708e1a36d836c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 13:03:55 +1200 Subject: [PATCH 8/9] Follow the bake-modernize README template. --- readme.md | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 939c6c1..0a302f4 100644 --- a/readme.md +++ b/readme.md @@ -2,14 +2,35 @@ Reviewed GitHub releases for Ruby gems, using `bake-gem` for branch preparation, independent content validation, and clean builds. -- `gem:github:release:patch` / `minor` / `major`: prepare, push and open a release PR. -- `gem:github:setup`: generate the three workflows and native review/CI policy. -- `gem:github:setup:plan` / `apply`: inspect and apply the managed GitHub rulesets. -- `gem:github:release:resume run=ID`: retry with the original artifact. +[![Development Status](https://github.com/socketry/bake-gem-github/workflows/Test/badge.svg)](https://github.com/socketry/bake-gem-github/actions?workflow=Test) -Read [the setup, release and recovery guide](https://github.com/socketry/bake-gem-github/blob/main/guides/getting-started/readme.md) before enabling publishing. Context is distributed through `agent-context`. Requires `bake-gem` 0.15 or later. +## Motivation -## Making Releases +Maintainers need a shared release process that they can run locally or through GitHub. This gem prepares release pull requests for review, validates their generated content, and publishes the merged release through GitHub Actions using RubyGems Trusted Publishing. Retained artifacts and attestations allow interrupted releases to resume using the original verified bytes. + +## Usage + +Please see the [project documentation](https://socketry.github.io/bake-gem-github/) or run it locally using `bake utopia:project:serve`. + +## Contributing + +We welcome contributions to this project. + +1. Fork the repository. +2. Create your feature branch (`git checkout -b my-new-feature`). +3. Commit your changes (`git commit -am 'Add some feature.'`). +4. Push to the branch (`git push origin my-new-feature`). +5. Create a new pull request. + +### Running Tests + +To run the test suite: + +``` bash +$ bundle exec sus +``` + +### Making Releases To prepare a release branch and open a pull request from an up-to-date `main`: @@ -19,10 +40,10 @@ $ bundle exec bake gem:github:release:patch # or minor or major See [bake-gem-github](https://github.com/socketry/bake-gem-github) for setup, remote releases, and recovery. -## Development +### Developer Certificate of Origin -Run `bundle exec bake test` for the test suite and `bundle exec rubocop` for style checks. The test, coverage, documentation, and RuboCop workflows follow `bake modernize` conventions. +In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. -Install maintenance dependencies with `BUNDLE_WITH=maintenance bundle install`, then run `BUNDLE_WITH=maintenance bundle exec bake agent:context:install` for local agent guidance. Generated `agents.md` and `.agents/context/` files are ignored. +### Community Guidelines -Review modernization changes before committing them. Retain the Socketry certificate, the `~/.gem/socketry-release.pem` signing key path, and packaged release templates. Publishing is handled by `release-publish.yaml`; do not add a second publishing hook to `bake.rb`. +This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. From ba4e10ae7a18c9de7400a64be35bffe7733078ee Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 13:08:14 +1200 Subject: [PATCH 9/9] Update project documentation during release preparation. --- bake-gem-github.gemspec | 1 + bake.rb | 6 ++++-- context/index.yaml | 1 + readme.md | 42 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/bake-gem-github.gemspec b/bake-gem-github.gemspec index f5c0f80..b4c958e 100644 --- a/bake-gem-github.gemspec +++ b/bake-gem-github.gemspec @@ -10,6 +10,7 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.homepage = "https://github.com/socketry/bake-gem-github" spec.metadata = { + "documentation_uri" => "https://socketry.github.io/bake-gem-github/", "bug_tracker_uri" => "https://github.com/socketry/bake-gem-github/issues", "changelog_uri" => "https://github.com/socketry/bake-gem-github/blob/main/releases.md", "source_code_uri" => "https://github.com/socketry/bake-gem-github.git", diff --git a/bake.rb b/bake.rb index 88e8076..7782d4a 100644 --- a/bake.rb +++ b/bake.rb @@ -3,8 +3,10 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -# Regenerate documentation when preparing a release. +# Update the project documentation with the new version number. +# +# @parameter version [String] The new version number. def after_gem_release_version_increment(version) context["releases:update"].call(version) - context["utopia:project:agent:context:update"].call + context["utopia:project:update"].call end diff --git a/context/index.yaml b/context/index.yaml index 399ec92..b05febb 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -3,6 +3,7 @@ --- description: Reviewable GitHub releases for Ruby gems. metadata: + documentation_uri: https://socketry.github.io/bake-gem-github/ bug_tracker_uri: https://github.com/socketry/bake-gem-github/issues changelog_uri: https://github.com/socketry/bake-gem-github/blob/main/releases.md source_code_uri: https://github.com/socketry/bake-gem-github.git diff --git a/readme.md b/readme.md index 0a302f4..1e34302 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,47 @@ Maintainers need a shared release process that they can run locally or through G ## Usage -Please see the [project documentation](https://socketry.github.io/bake-gem-github/) or run it locally using `bake utopia:project:serve`. +Please see the [project documentation](https://socketry.github.io/bake-gem-github/) for more details. + + - [Getting Started](https://socketry.github.io/bake-gem-github/guides/getting-started/index) - This guide explains how to configure reviewed Ruby gem releases and prepare the first release PR with `bake-gem-github`. + + - [Preparing Releases](https://socketry.github.io/bake-gem-github/guides/preparing-releases/index) - This guide explains how to request a release PR, resume interrupted preparation, and refresh generated content when the default branch changes. + + - [Verifying Releases](https://socketry.github.io/bake-gem-github/guides/verifying-releases/index) - This guide explains how publishing binds a gem to its reviewed source and how to verify the downloaded artifact and attestations. + + - [Recovering Releases](https://socketry.github.io/bake-gem-github/guides/recovering-releases/index) - This guide explains how to resume an interrupted publishing workflow using the original gem and its verification evidence. + +## Releases + +Please see the [project releases](https://socketry.github.io/bake-gem-github/releases/index) for all releases. + +### Unreleased + + - Stop generating `.github/releasing.md`; release instructions are maintained in the shared guide and agent context. + - Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits. + - Preserve all release files in one archive before individual asset uploads, so reruns can recover interrupted drafts. + +### v0.2.0 + + - Use only the version tag for GitHub release titles. + +### v0.1.0 + + - Include the version's release notes in GitHub releases using `bake-releases`. + - Update generated release files in the working tree with `gem:github:setup:update`. + +### v0.0.5 + + - Preserve and recover release files even when GitHub's release list is stale. + +### v0.0.4 + + - Preserve verified release files in a draft GitHub release before uploading to RubyGems, so reruns can recover when Actions artifacts disappear. + - Wait for RubyGems registry propagation before finalizing releases. + +### v0.0.1 + + - Initial implementation. ## Contributing