diff --git a/.github/release-rules/checks.json b/.github/release-rules/checks.json index bc5d123..0be6e5a 100644 --- a/.github/release-rules/checks.json +++ b/.github/release-rules/checks.json @@ -37,7 +37,13 @@ "context": "RuboCop" }, { - "context": "Documentation coverage" + "context": "ruby on ubuntu" + }, + { + "context": "ruby on macos" + }, + { + "context": "validate" }, { "context": "Release validation" diff --git a/.github/workflows/documentation-coverage.yaml b/.github/workflows/documentation-coverage.yaml index 41e79fa..6d06003 100644 --- a/.github/workflows/documentation-coverage.yaml +++ b/.github/workflows/documentation-coverage.yaml @@ -1,4 +1,4 @@ -name: Documentation coverage +name: Documentation Coverage on: [push, pull_request] @@ -6,18 +6,20 @@ permissions: contents: read env: + COVERAGE: PartialSummary BUNDLE_WITH: maintenance jobs: validate: - name: Documentation coverage runs-on: ubuntu-latest + steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1 - with: - ruby-version: "3.4" - bundler-cache: true - - name: Check library documentation - timeout-minutes: 5 - run: bundle exec bake decode:index:coverage lib + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler-cache: true + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake decode:index:coverage lib diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..f13cdac --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,58 @@ +name: Test Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + COVERAGE: PartialSummary + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - ruby + + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 5 + run: bundle exec bake test + + - uses: actions/upload-artifact@v7 + with: + include-hidden-files: true + if-no-files-found: error + name: coverage-${{matrix.os}}-${{matrix.ruby}} + path: .covered.db + + validate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler-cache: true + + - uses: actions/download-artifact@v8 + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake covered:validate --paths */.covered.db \; diff --git a/config/release.yaml b/config/release.yaml index 857e0a4..2a2fef6 100644 --- a/config/release.yaml +++ b/config/release.yaml @@ -7,7 +7,9 @@ checks: - Test Ruby 3.4 - Test Ruby 4.0 - RuboCop -- Documentation coverage +- ruby on ubuntu +- ruby on macos +- validate - Release validation approvals: 2 signing: true diff --git a/fixtures/bake/gem/github/project_client.rb b/fixtures/bake/gem/github/project_client.rb new file mode 100644 index 0000000..78940f9 --- /dev/null +++ b/fixtures/bake/gem/github/project_client.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/project" +require "bake/context" + +module Bake + module Gem + module GitHub + # Substitute GitHub transport while retaining real Git and Bake operations. + class ProjectClient < Project + attr_accessor :pulls, :responses + attr_reader :requests, :writes + + def initialize(root) + super + @pulls = [] + @responses = {} + @requests = [] + @writes = [] + end + + def readlines(*arguments, **options) + return super unless arguments.first == "gh" + @requests << arguments + case arguments[1, 2] + when ["pr", "list"] + [JSON.generate(@pulls)] + when ["pr", "create"] + @writes << File.read(arguments[arguments.index("--body-file") + 1]) + ["https://github.com/socketry/example/pull/42\n"] + else + [JSON.generate(@responses.fetch(arguments.fetch(2)))] + end + end + + def system(*arguments, **options) + return super unless arguments.first == "gh" + @requests << arguments + @writes << JSON.parse(File.read(arguments[arguments.index("--input") + 1])) + true + end + end + end + end +end diff --git a/fixtures/bake/gem/github/repository_context.rb b/fixtures/bake/gem/github/repository_context.rb new file mode 100644 index 0000000..9c6c00d --- /dev/null +++ b/fixtures/bake/gem/github/repository_context.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "sus/shared" +require "sus/fixtures/temporary_directory_context" +require "sus/fixtures/isolated_ruby_context" +require "bake/gem/github/setup" + +module Bake + module Gem + module GitHub + RepositoryContext = Sus::Shared("a gem repository") do + include Sus::Fixtures::TemporaryDirectoryContext + include Sus::Fixtures::IsolatedRubyContext + + def repository + File.join(root, "source") + end + + def git(*arguments, chdir: repository) + output = IO.popen(["git", *arguments], chdir: chdir, &:read) + raise "git failed: #{arguments.inspect}" unless $?.success? + output.strip + end + + before do + FileUtils.mkdir_p(File.join(repository, "lib/example")) + File.write(File.join(repository, "lib/example/version.rb"), "module Example; VERSION = \"1.0.0\"; end\n") + File.write(File.join(repository, "example.gemspec"), <<~RUBY) + require_relative "lib/example/version" + Gem::Specification.new do |spec| + spec.name = "example" + spec.version = Example::VERSION + spec.summary = "Example consumer" + spec.authors = ["Test"] + spec.license = "MIT" + spec.homepage = "https://example.com" + spec.required_ruby_version = ">= 3.3" + spec.files = Dir.glob(["lib/**/*.rb", "releases.md"]) + end + RUBY + File.write(File.join(repository, "releases.md"), "## Unreleased\n\nA change.\n") + File.write(File.join(repository, "bake.rb"), <<~'RUBY') + def after_gem_release_version_increment(version) + File.write("releases.md", File.read("releases.md").sub("Unreleased", version.to_s)) + end + RUBY + File.write(File.join(repository, ".gitignore"), "/pkg/\n") + Setup.new(repository).generate(repository: "socketry/example", checks: ["Tests"], signing: false) + git("init", "--quiet", "--initial-branch=main") + git("config", "core.hooksPath", File::NULL) + git("config", "commit.gpgsign", "false") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.com") + git("add", "--all") + git("commit", "--quiet", "-m", "Initial source") + git("init", "--quiet", "--bare", "--initial-branch=main", File.join(root, "remote")) + git("remote", "add", "origin", File.join(root, "remote")) + git("push", "--quiet", "--set-upstream", "origin", "main") + end + + def isolated_project(source, env: {}) + isolated_ruby(source, chdir: repository, env: env, requires: ["bundler/setup", File.expand_path("project_client.rb", __dir__)]) + end + end + end + end +end diff --git a/gems.rb b/gems.rb index 3a11bfd..a865e66 100644 --- a/gems.rb +++ b/gems.rb @@ -5,6 +5,7 @@ gemspec group :test do + gem "bake-test" gem "covered" gem "sus" gem "rubocop" diff --git a/test/bake/gem/github/project.rb b/test/bake/gem/github/project.rb new file mode 100644 index 0000000..5912dd8 --- /dev/null +++ b/test/bake/gem/github/project.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/repository_context" +require "bake/gem/github/project_client" + +describe Bake::Gem::GitHub::Project do + include Bake::Gem::GitHub::RepositoryContext + + with "#prepare" do + it "prepares, validates, and pushes a release before opening its PR" do + result = isolated_project(<<~'RUBY') + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + 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" + expect(git("rev-parse", "HEAD")).to be == git("rev-parse", "refs/heads/releases/v1.0.1", chdir: File.join(root, "remote")) + expect(File.read(File.join(repository, "releases.md"))).to be(:include?, "## v1.0.1") + expect(git("tag", "--list")).to be == "" + git("checkout", "--quiet", "main") + git("merge", "--quiet", "--no-ff", "releases/v1.0.1", "-m", "Merge release") + git("push", "--quiet", "origin", "main") + commit = git("rev-parse", "HEAD") + project = Bake::Gem::GitHub::ProjectClient.new(repository) + project.responses["repos/socketry/example/pulls/42"] = { + "number" => 42, "merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, + "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 + + it "reports an existing release PR without creating a branch" do + url = isolated_project(<<~'RUBY') + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + 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" + end + + 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/) + end + + 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/) + end + end + + with "#merged and #inspect_release" do + let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)} + let(:pull) {{"merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, "merge_commit_sha" => git("rev-parse", "HEAD")}} + + before do + project.responses["repos/socketry/example/pulls/42"] = pull + end + + 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 + + it "rejects a commit outside the remote default branch history" do + 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 + + it "does not publish ordinary merged changes" do + File.write(File.join(repository, "readme.md"), "An ordinary change.\n") + git("add", "readme.md") + 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 + + with "#doctor and #apply" do + let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)} + let(:rules) {[]} + + before do + project.responses["repos/socketry/example/rulesets?per_page=100"] = rules + project.responses["repos/socketry/example/environments"] = {"environments" => []} + end + + 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" + expect(project.writes).to be == [] + end + + 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 + + it "updates existing managed rulesets by ID" do + Bake::Gem::GitHub::Setup.rules(project.config).values.each_with_index do |rule, index| + 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 + end +end diff --git a/test/bake/gem/github/publisher.rb b/test/bake/gem/github/publisher.rb new file mode 100644 index 0000000..5443cb1 --- /dev/null +++ b/test/bake/gem/github/publisher.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/publisher" +require "open3" +require "sus/fixtures/temporary_directory_context" +require "sus/fixtures/isolated_ruby_context" + +describe Bake::Gem::GitHub::Publisher do + include Sus::Fixtures::TemporaryDirectoryContext + include Sus::Fixtures::IsolatedRubyContext + + let(:publisher) {subject.new(root)} + let(:commit) {git("rev-parse", "HEAD")} + + def git(*arguments) + output, status = Open3.capture2e("git", *arguments, chdir: root) + raise output unless status.success? + output.strip + end + + before do + Bake::Gem::GitHub::Setup.new(root).generate(repository: "socketry/example", checks: ["Test"], signing: false) + git("init", "--initial-branch=main") + git("config", "core.hooksPath", File::NULL) + git("config", "user.name", "Test") + git("config", "user.email", "test@example.com") + git("add", "--all") + git("commit", "-m", "Initial source") + end + + it "detects a changed retained artifact" do + FileUtils.mkdir_p(File.join(root, "pkg")) + 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 + + it "rejects existing tags which name a different commit" do + 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 + + it "rejects unmerged PRs before any git or publishing command" do + expect(publisher).to receive(:api).with("pulls/42").and_return({"merged" => false}) + expect{publisher.merged(42)}.to raise_exception(RuntimeError, message: be =~ /must be merged/) + end + + it "rejects a PR merged into another branch" do + expect(publisher).to receive(:api).with("pulls/42").and_return({"merged" => true, "base" => {"ref" => "other"}}) + expect{publisher.merged(42)}.to raise_exception(RuntimeError, message: be =~ /configured branch/) + end + + it "certificate-signs committed source and excludes dirty checkout content" do + key = OpenSSL::PKey::RSA.new(2048) + certificate = OpenSSL::X509::Certificate.new + certificate.version = 2 + certificate.serial = 1 + certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=Release Test") + certificate.public_key = key.public_key + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + 3600 + certificate.sign(key, OpenSSL::Digest.new("SHA256")) + File.write(File.join(root, "release.cert"), certificate.to_pem) + File.write(File.join(root, "example.rb"), "ORIGINAL") + File.write(File.join(root, "example.gemspec"), <<~RUBY) + Gem::Specification.new do |spec| + spec.name = "example" + spec.version = "1.0.1" + spec.authors = ["Test"] + spec.summary = "Test gem" + spec.files = ["example.rb"] + spec.cert_chain = ["release.cert"] + end + RUBY + git("add", "--all") + git("commit", "-m", "Gem source and public certificate") + File.write(File.join(root, "example.rb"), "DIRTY") + result = isolated_ruby(<<~'RUBY', chdir: root, env: {"GEM_SIGNING_KEY" => key.to_pem}, requires: ["bundler/setup", "bake/gem/github/publisher"]) + publisher = Bake::Gem::GitHub::Publisher.new(Dir.pwd) + publisher.config["signing"] = true + package_path = publisher.send(:build_package, File.join(Dir.pwd, "pkg")) + package = Gem::Package.new(package_path, Gem::Security::Policy.new("Release", only_trusted: false)) + package.verify + 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 +end diff --git a/test/bake/gem/github/publisher/environment.rb b/test/bake/gem/github/publisher/environment.rb new file mode 100644 index 0000000..77a0580 --- /dev/null +++ b/test/bake/gem/github/publisher/environment.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/repository_context" +require "bake/gem/github/publisher" + +describe Bake::Gem::GitHub::Publisher do + include Bake::Gem::GitHub::RepositoryContext + + it "builds unsigned committed source without a private key" do + result = isolated_project(<<~'RUBY', env: {"GEM_SIGNING_KEY" => nil}) + require "bake/gem/github/publisher" + publisher = Bake::Gem::GitHub::Publisher.new(Dir.pwd) + path = publisher.send(:build_package, File.join(Dir.pwd, "pkg")) + 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 + + it "accepts only the configured repository and default branch environment" do + result = isolated_project(<<~'RUBY', env: {"GITHUB_REPOSITORY" => "socketry/example", "GITHUB_REF" => "refs/heads/main"}) + require "bake/gem/github/publisher" + Bake::Gem::GitHub::Publisher.new(Dir.pwd).send(:guard_environment) + true + RUBY + expect(result).to be == true + end + + [ + [{"GITHUB_REPOSITORY" => "elsewhere/example", "GITHUB_REF" => "refs/heads/main"}, /configured GitHub repository/], + [{"GITHUB_REPOSITORY" => "socketry/example", "GITHUB_REF" => "refs/pull/42/merge"}, /default branch workflow/] + ].each do |environment, message| + it "rejects an unauthorized publishing environment", unique: environment do + expect do + isolated_project(<<~'RUBY', env: environment) + require "bake/gem/github/publisher" + Bake::Gem::GitHub::Publisher.new(Dir.pwd).send(:guard_environment) + RUBY + end.to raise_exception(RuntimeError, message: be =~ message) + end + end + + it "emits restored artifact outputs for later workflow steps" do + output = File.join(root, "output") + isolated_project(<<~'RUBY', env: {"GITHUB_OUTPUT" => output}) + require "bake/gem/github/publisher" + 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 + + it "clears Bundler variables only inside the gem command" do + result = isolated_project(<<~'RUBY') + require "bake/gem/github/publisher" + publisher = Bake::Gem::GitHub::Publisher.new(Dir.pwd) + def publisher.system(*arguments, **options) + {arguments: arguments, gemfile: ENV["BUNDLE_GEMFILE"], directory: options[:chdir]} + end + original = ENV.fetch("BUNDLE_GEMFILE") + 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 + + it "runs gem commands from an unbundled Ruby process" do + # Remove Bundler's startup hook while retaining other inherited hooks: + environment = {"RUBYOPT" => ENV["RUBYOPT"]&.gsub(/-r\S*bundler\/setup\s*/, ""), "BUNDLER_SETUP" => nil} + result = isolated_ruby(<<~'RUBY', chdir: repository, env: environment, requires: [File.join(::Gem.loaded_specs.fetch("bake-gem-github").full_gem_path, "lib/bake/gem/github/publisher")]) + raise "Bundler is still loaded" if defined?(Bundler) + publisher = Bake::Gem::GitHub::Publisher.new(Dir.pwd) + def publisher.system(*arguments, **options) + {arguments: arguments, directory: options[:chdir]} + 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/provenance.rb b/test/bake/gem/github/publisher/provenance.rb similarity index 96% rename from test/provenance.rb rename to test/bake/gem/github/publisher/provenance.rb index 11f6a54..0aa9e6d 100644 --- a/test/provenance.rb +++ b/test/bake/gem/github/publisher/provenance.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/publisher" describe Bake::Gem::GitHub::Publisher do with "#verify_provenance" do diff --git a/test/recovery.rb b/test/bake/gem/github/publisher/recovery.rb similarity index 96% rename from test/recovery.rb rename to test/bake/gem/github/publisher/recovery.rb index 1be92cd..fc80838 100644 --- a/test/recovery.rb +++ b/test/bake/gem/github/publisher/recovery.rb @@ -5,6 +5,7 @@ require "bake/gem/github/recovery_publisher" require "sus/fixtures/temporary_directory_context" +require "bake/context" describe "Publication recovery" do include Sus::Fixtures::TemporaryDirectoryContext @@ -24,7 +25,8 @@ def before def restore FileUtils.rm_rf(File.join(@root, "pkg")) expect(ENV).to receive(:fetch).with("GITHUB_RUN_ID").and_return("123") - @publisher.build(42) + 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 it "resumes finalization after upload without uploading or rebuilding again" do @@ -32,7 +34,8 @@ def restore expect{@publisher.publish(42)}.to raise_exception(RuntimeError, message: be =~ /GitHub unavailable/) @publisher.fail_release = false restore - @publisher.publish(42) + 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") diff --git a/test/registry.rb b/test/bake/gem/github/publisher/registry.rb similarity index 79% rename from test/registry.rb rename to test/bake/gem/github/publisher/registry.rb index 7791d94..3fbb03e 100644 --- a/test/registry.rb +++ b/test/bake/gem/github/publisher/registry.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/publisher" require "sus/fixtures/temporary_directory_context" describe Bake::Gem::GitHub::Publisher do @@ -59,7 +59,7 @@ def verify end it "rejects an unrelated attestation immediately" do - attestations.replace(["[]"]) + attestations.replace([JSON.generate([{bundle: {mediaType: "unrelated"}}])]) expect{verify}.to raise_exception(RuntimeError, message: be =~ /Sigstore bundle/) expect(waits).to be == [] end @@ -126,5 +126,25 @@ def response(code, body = "") 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 + + 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") + 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 end diff --git a/test/bake/gem/github/release.rb b/test/bake/gem/github/release.rb new file mode 100644 index 0000000..48f4d7f --- /dev/null +++ b/test/bake/gem/github/release.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/repository_context" +require "bake/gem/github/project_client" + +describe "GitHub release tasks" do + include Bake::Gem::GitHub::RepositoryContext + + with "gem:github:setup" do + it "discovers the canonical repository and default branch through gh" do + bin = File.join(root, "bin") + FileUtils.mkdir_p(bin) + File.write(File.join(bin, "gh"), <<~'RUBY') + #!/usr/bin/env ruby + require "json" + abort "Unexpected GitHub command" unless ARGV == ["repo", "view", "--json", "nameWithOwner,defaultBranchRef"] + puts JSON.generate(nameWithOwner: "socketry/example", defaultBranchRef: {name: "main"}) + 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 + + %w[patch minor major].zip(%w[1.0.1 1.1.0 2.0.0]).each do |bump, version| + with "gem:github:release:#{bump}" do + it "prepares the requested release through the public task" do + result = isolated_project(<<~'RUBY', env: {"BUMP" => bump}) + require "sus/mock" + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + 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 + end + end + + with "gem:github:release:resolve" do + [false, true].each do |release| + it "writes workflow outputs for the actual merged PR", unique: release do + output = File.join(root, "output") + result = isolated_project(<<~'RUBY', env: {"RELEASE" => release.to_s, "RELEASE_PR" => "42", "GITHUB_OUTPUT" => output}) + require "sus/mock" + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + context = Bake::Context.load(Dir.pwd) + if ENV.fetch("RELEASE") == "true" + project.prepare(context, "patch") + project.system("git", "checkout", "--quiet", "main") + project.system("git", "merge", "--quiet", "--no-ff", "releases/v1.0.1", "-m", "Merge release") + else + project.system("git", "commit", "--quiet", "--allow-empty", "-m", "Ordinary change") + end + project.system("git", "push", "--quiet", "origin", "main") + commit = project.readlines("git", "rev-parse", "HEAD").join.strip + project.responses["repos/socketry/example/pulls/42"] = { + "number" => 42, "merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, + "merge_commit_sha" => commit, "html_url" => "https://github.com/socketry/example/pull/42" + } + 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 + end + end + + with "gem:github:release:resume" do + let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)} + let(:context) {Bake::Context.load(repository)} + + before do + expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project) + end + + 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 + end +end diff --git a/test/setup.rb b/test/bake/gem/github/setup.rb similarity index 71% rename from test/setup.rb rename to test/bake/gem/github/setup.rb index e772ccd..19881ce 100644 --- a/test/setup.rb +++ b/test/bake/gem/github/setup.rb @@ -3,29 +3,25 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require_relative "../lib/bake/gem/github/setup" -require "tmpdir" +require "bake/gem/github/setup" +require "sus/fixtures/temporary_directory_context" require "bake/context" describe Bake::Gem::GitHub::Setup do - def around - Dir.mktmpdir do |root| - @root = root - @setup = subject.new(root) - yield - end - end + include Sus::Fixtures::TemporaryDirectoryContext + + let(:setup) {subject.new(root)} def generate - @setup.generate(repository: "socketry/example", checks: ["Ruby 3.4"], signing: false) + setup.generate(repository: "socketry/example", checks: ["Ruby 3.4"], signing: false) end it "accepts the documented setup command and discovers all workflow tasks" do registry = Bake::Registry::Aggregate.new - registry.append_path(File.expand_path("..", __dir__)) - context = Bake::Context.new(registry, @root) + registry.append_path(::Gem.loaded_specs.fetch("bake-gem-github").full_gem_path) + 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")) + 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| @@ -38,21 +34,21 @@ def generate expect(generate).to be == paths expect(paths.grep(/workflows/).size).to be == 3 paths.grep(/workflows/).each do |path| - workflow = YAML.safe_load_file(File.join(@root, 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"))) + 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 it "keeps unmerged validation read-only and retains artifacts before credentials" do generate - validation = File.read(File.join(@root, ".github/workflows/release-validate.yaml")) + 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")) + 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") @@ -60,7 +56,7 @@ def generate it "refuses to overwrite manually changed workflows" do generate - path = File.join(@root, ".github/workflows/release-validate.yaml") + 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" @@ -68,7 +64,7 @@ def generate it "attests both the gem and its source receipt with native provenance" do generate - workflow = YAML.safe_load_file(File.join(@root, ".github/workflows/release-publish.yaml")) + 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 diff --git a/test/publisher.rb b/test/publisher.rb deleted file mode 100644 index 33130e5..0000000 --- a/test/publisher.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "../lib/bake/gem/github/publisher" -require "open3" - -describe Bake::Gem::GitHub::Publisher do - def git(*arguments) - output, status = Open3.capture2e("git", *arguments, chdir: @root) - raise output unless status.success? - output.strip - end - - def around - Dir.mktmpdir do |root| - @root = root - Bake::Gem::GitHub::Setup.new(root).generate(repository: "socketry/example", checks: ["Test"], signing: false) - git("init", "--initial-branch=main") - git("config", "core.hooksPath", File::NULL) - git("config", "user.name", "Test") - git("config", "user.email", "test@example.com") - git("add", "--all") - git("commit", "-m", "Initial source") - @commit = git("rev-parse", "HEAD") - @publisher = subject.new(root) - yield - end - end - - it "detects a changed retained artifact" do - FileUtils.mkdir_p(File.join(@root, "pkg")) - 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 - - it "rejects existing tags which name a different commit" do - 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 - - it "rejects unmerged PRs before any git or publishing command" do - expect(@publisher).to receive(:api).with("pulls/42").and_return({"merged" => false}) - expect{@publisher.merged(42)}.to raise_exception(RuntimeError, message: be =~ /must be merged/) - end - - it "rejects a PR merged into another branch" do - expect(@publisher).to receive(:api).with("pulls/42").and_return({"merged" => true, "base" => {"ref" => "other"}}) - expect{@publisher.merged(42)}.to raise_exception(RuntimeError, message: be =~ /configured branch/) - end - - it "certificate-signs committed source and excludes dirty checkout content" do - key = OpenSSL::PKey::RSA.new(2048) - certificate = OpenSSL::X509::Certificate.new - certificate.version = 2 - certificate.serial = 1 - certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=Release Test") - certificate.public_key = key.public_key - certificate.not_before = Time.now - 60 - certificate.not_after = Time.now + 3600 - certificate.sign(key, OpenSSL::Digest.new("SHA256")) - File.write(File.join(@root, "release.cert"), certificate.to_pem) - File.write(File.join(@root, "example.rb"), "ORIGINAL") - File.write(File.join(@root, "example.gemspec"), <<~RUBY) - Gem::Specification.new do |spec| - spec.name = "example" - spec.version = "1.0.1" - spec.authors = ["Test"] - spec.summary = "Test gem" - spec.files = ["example.rb"] - spec.cert_chain = ["release.cert"] - end - RUBY - git("add", "--all") - git("commit", "-m", "Gem source and public certificate") - File.write(File.join(@root, "example.rb"), "DIRTY") - @publisher.config["signing"] = true - previous = ENV["GEM_SIGNING_KEY"] - begin - ENV["GEM_SIGNING_KEY"] = key.to_pem - package_path = @publisher.send(:build_package, File.join(@root, "pkg")) - package = Gem::Package.new(package_path, Gem::Security::Policy.new("Release", only_trusted: false)) - package.verify - package.extract_files(File.join(@root, "extracted")) - expect(File.read(File.join(@root, "extracted", "example.rb"))).to be == "ORIGINAL" - expect(OpenSSL::X509::Certificate.new(package.spec.cert_chain.last).to_der).to be == certificate.to_der - ensure - ENV["GEM_SIGNING_KEY"] = previous - end - end - -end