From b54de8423f6c49d8e05a7e7673cf18131d204807 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 14:34:43 +1200 Subject: [PATCH 1/8] Prepare and validate release branches before publication --- bake/gem.rb | 2 +- bake/gem/release.rb | 10 ++ bake/gem/release/branch.rb | 28 ++--- bake/gem/release/version.rb | 20 ++-- context/getting-started.md | 33 +++--- guides/getting-started/readme.md | 33 +++--- lib/bake/gem/helper.rb | 57 ++++++---- lib/bake/gem/release.rb | 104 +++++++++++++++++++ lib/bake/gem/release/worker.rb | 37 +++++++ test/bake/gem/helper.rb | 4 + test/bake/gem/release.rb | 172 +++++++++++++++++++++++++++++++ 11 files changed, 416 insertions(+), 84 deletions(-) create mode 100644 lib/bake/gem/release.rb create mode 100644 lib/bake/gem/release/worker.rb create mode 100644 test/bake/gem/release.rb diff --git a/bake/gem.rb b/bake/gem.rb index 9ed5e9e..abda0ae 100644 --- a/bake/gem.rb +++ b/bake/gem.rb @@ -70,7 +70,7 @@ def release(tag: true) raise end - @helper.push_release(current_branch: current_branch) + @helper.push_release(current_branch: current_branch, tag: tag_name) context["after_gem_release"]&.call(name: @helper.gemspec.name, version: version, tag: tag_name, path: path) return { diff --git a/bake/gem/release.rb b/bake/gem/release.rb index 662a0ae..3ea086b 100644 --- a/bake/gem/release.rb +++ b/bake/gem/release.rb @@ -35,3 +35,13 @@ def major(tag: true) release_task = context.lookup("gem:release") release_task.call(tag: tag) end + +# Regenerate release content from the base commit and compare it with the candidate. +# @parameter base [String] The current target commit (first parent when publishing). +# @parameter candidate [String] The proposed or merged release commit. +# @parameter optional [Boolean] Accept ordinary PRs with no version change. +def validate(base:, candidate: "HEAD", optional: false) + require_relative "../../lib/bake/gem/helper" + require_relative "../../lib/bake/gem/release" + Bake::Gem::Release.new(context.root).validate(base: base, candidate: candidate, optional: optional) +end diff --git a/bake/gem/release/branch.rb b/bake/gem/release/branch.rb index 5d05499..1442b80 100644 --- a/bake/gem/release/branch.rb +++ b/bake/gem/release/branch.rb @@ -25,21 +25,15 @@ def major def commit(bump, message: "Bump version.") release = context.lookup("gem:release") helper = release.instance.helper - gemspec = helper.gemspec - - # helper.guard_clean - - version_path = context.lookup("gem:release:version:increment").call(bump, message: message) - - if version_path - branch_name = helper.create_release_branch(version_path, message: message) - else - raise "Could not find version number!" - end - - return { - version: gemspec.version, - version_path: version_path, - branch: branch_name, - } + helper.guard_clean + helper.guard_last_commit_not_version_bump + path = helper.version_path or raise "Could not find version file!" + line = File.read(File.expand_path(path, helper.root)) + version = nil + Bake::Gem::Version.update_version(line){|current| version = current.increment(bump)} + raise "Could not find version number!" unless version + branch_name = helper.create_release_branch(version: version.join) + result = context.lookup("gem:release:version:increment").call(bump, message: message) + helper.commit_version_changes(message: message) + return result.merge(branch: branch_name) end diff --git a/bake/gem/release/version.rb b/bake/gem/release/version.rb index 127a264..194a71c 100644 --- a/bake/gem/release/version.rb +++ b/bake/gem/release/version.rb @@ -27,7 +27,7 @@ def increment(bump, message: "Bump version.") helper = release.instance.helper gemspec = helper.gemspec - helper.update_version(bump) do |version| + version_path = helper.update_version(bump) do |version| Console.info(self, "Updated version:", version: version) # Ensure that any subsequent tasks use the correct version! @@ -35,10 +35,11 @@ def increment(bump, message: "Bump version.") after_increment(version) end + raise "Could not find version number!" unless version_path return { version: gemspec.version, - version_path: helper.version_path, + version_path: version_path, } end @@ -52,18 +53,9 @@ def commit(bump, message: "Bump version.") helper.guard_clean - version_path = increment(bump, message: message) - - if version_path - helper.commit_version_changes(message: message) - else - raise "Could not find version number!" - end - - return { - version: helper.gemspec.version, - version_path: version_path, - } + result = increment(bump, message: message) + helper.commit_version_changes(message: message) + return result end protected diff --git a/context/getting-started.md b/context/getting-started.md index 281e357..26b883b 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -73,7 +73,7 @@ $ bake gem:release ### Automated CI/CD Pipeline -For releasing gems via automated pipelines, use a two-step process: +Use `bake-gem-github` for GitHub pull requests, native approval rules, Trusted Publishing and attestations. The provider-independent preparation tasks below work identically locally and in CI. #### Step 1: Create Release Branch (Locally) @@ -83,22 +83,25 @@ $ bake gem:release:branch:patch # or minor/major ``` This will: -- Create a new branch named `releases/v[new-version]` +- Require a clean checkout on a branch +- Create a new branch named `release-v[new-version]` before modifying files - Bump the gem version -- Commit the version change -- Push the branch to origin +- Run `after_gem_release_version_increment` and commit all changes, including added and deleted documentation + +This task does not push, open a PR, create tags or publish. Select a current base before running it; the GitHub companion additionally fetches and checks the default branch. Failed hooks leave changes available for inspection. #### Step 2: Release from CI (After Merge) -Once the release branch is merged into main: +The GitHub companion handles publishing the exact merged commit. To independently validate release content, supply the current target commit and proposed commit: ``` bash -$ export RUBYGEMS_HOST=https://rubygems.org -$ export GEM_HOST_API_KEY=your_api_key - -$ bake gem:release +$ bundle exec bake gem:release:validate base=origin/main candidate=HEAD ``` +Validation creates a temporary checkout of the base, applies the proposed patch/minor/major bump, runs the same hooks, and compares the complete generated tree with the candidate. It never bumps the candidate again or modifies your checkout. Stale notes and unexpected file additions/deletions fail with a diff. A rebase passes when the generated content still matches. Hooks must be repeatable for the same source and version. + +For an ordinary PR check, add `optional=true` to accept candidates without a version change. After merge, use the merged commit's first parent as `base` and the merged commit as `candidate`; later changes on `main` do not affect that release boundary. + ### Individual Commands You can also run individual steps: @@ -185,12 +188,10 @@ $ bake gem:release:patch ``` bash # Create release branch $ bake gem:release:branch:minor -# Creates branch: releases/v1.3.0 -# Commits version bump -# Pushes branch +# Creates branch: release-v1.3.0 +# Commits the version bump and release-hook output +# Leaves the branch local for inspection -# After code review and merge: -$ git checkout main -$ git pull -$ bake gem:release +# Validate before pushing or opening a PR: +$ bake gem:release:validate base=main ``` diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 281e357..26b883b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -73,7 +73,7 @@ $ bake gem:release ### Automated CI/CD Pipeline -For releasing gems via automated pipelines, use a two-step process: +Use `bake-gem-github` for GitHub pull requests, native approval rules, Trusted Publishing and attestations. The provider-independent preparation tasks below work identically locally and in CI. #### Step 1: Create Release Branch (Locally) @@ -83,22 +83,25 @@ $ bake gem:release:branch:patch # or minor/major ``` This will: -- Create a new branch named `releases/v[new-version]` +- Require a clean checkout on a branch +- Create a new branch named `release-v[new-version]` before modifying files - Bump the gem version -- Commit the version change -- Push the branch to origin +- Run `after_gem_release_version_increment` and commit all changes, including added and deleted documentation + +This task does not push, open a PR, create tags or publish. Select a current base before running it; the GitHub companion additionally fetches and checks the default branch. Failed hooks leave changes available for inspection. #### Step 2: Release from CI (After Merge) -Once the release branch is merged into main: +The GitHub companion handles publishing the exact merged commit. To independently validate release content, supply the current target commit and proposed commit: ``` bash -$ export RUBYGEMS_HOST=https://rubygems.org -$ export GEM_HOST_API_KEY=your_api_key - -$ bake gem:release +$ bundle exec bake gem:release:validate base=origin/main candidate=HEAD ``` +Validation creates a temporary checkout of the base, applies the proposed patch/minor/major bump, runs the same hooks, and compares the complete generated tree with the candidate. It never bumps the candidate again or modifies your checkout. Stale notes and unexpected file additions/deletions fail with a diff. A rebase passes when the generated content still matches. Hooks must be repeatable for the same source and version. + +For an ordinary PR check, add `optional=true` to accept candidates without a version change. After merge, use the merged commit's first parent as `base` and the merged commit as `candidate`; later changes on `main` do not affect that release boundary. + ### Individual Commands You can also run individual steps: @@ -185,12 +188,10 @@ $ bake gem:release:patch ``` bash # Create release branch $ bake gem:release:branch:minor -# Creates branch: releases/v1.3.0 -# Commits version bump -# Pushes branch +# Creates branch: release-v1.3.0 +# Commits the version bump and release-hook output +# Leaves the branch local for inspection -# After code review and merge: -$ git checkout main -$ git pull -$ bake gem:release +# Validate before pushing or opening a PR: +$ bake gem:release:validate base=main ``` diff --git a/lib/bake/gem/helper.rb b/lib/bake/gem/helper.rb index 509ae85..0eff21e 100644 --- a/lib/bake/gem/helper.rb +++ b/lib/bake/gem/helper.rb @@ -89,7 +89,7 @@ class Helper # @parameter root [String] The root directory of the gem project. # @parameter gemspec [Gem::Specification | Nil] The gemspec to use, or nil to find it automatically. def initialize(root = Dir.pwd, gemspec: nil) - @root = root + @root = File.expand_path(root) @gemspec = gemspec || find_gemspec end @@ -127,7 +127,8 @@ def update_version(bump, version_path = self.version_path) # Guard against consecutive version bumps guard_last_commit_not_version_bump - lines = File.readlines(version_path) + path = File.expand_path(version_path, @root) + lines = File.readlines(path) new_version = nil lines.each do |line| @@ -137,7 +138,7 @@ def update_version(bump, version_path = self.version_path) end if new_version - File.write(version_path, lines.join) + File.write(path, lines.join) if block_given? yield new_version @@ -194,6 +195,7 @@ def guard_last_commit_not_version_bump # @returns [String] The path to the built gem package. def build_gem(root: "pkg", signing_key: nil) # Ensure the output directory exists: + root = File.expand_path(root, @root) FileUtils.mkdir_p(root) output_path = File.join(root, @gemspec.file_name) @@ -206,7 +208,9 @@ def build_gem(root: "pkg", signing_key: nil) raise ArgumentError, "Signing key is required for signing the gem, but none was specified by the gemspec." end - ::Gem::Package.build(@gemspec, false, false, output_path) + Dir.chdir(@root) do + ::Gem::Package.build(@gemspec, false, false, output_path) + end end # Install the gem using the `gem install` command. @@ -228,7 +232,7 @@ def push_gem(*arguments, path: @gemspec.file_name) # @parameter signing_key [String | Nil] The signing key to use for signing the package. # @returns [String] The path to the built gem package. def build_gem_in_worktree(root: "pkg", signing_key: nil) - original_pkg_path = File.join(@root, root) + original_pkg_path = File.expand_path(root, @root) # Create a unique temporary path for the worktree timestamp = Time.now.strftime("%Y%m%d-%H%M%S-%N") @@ -236,15 +240,13 @@ def build_gem_in_worktree(root: "pkg", signing_key: nil) begin # Create worktree from current HEAD - unless system("git", "worktree", "add", worktree_path, "HEAD", chdir: @root) + unless system("git", "worktree", "add", "--detach", worktree_path, "HEAD", chdir: @root) raise "Failed to create git worktree. Make sure you have at least one commit in the repository." end # Create helper for the worktree - worktree_helper = self.class.new(worktree_path) - - # Build gem directly into the target pkg directory - output_path = worktree_helper.build_gem(root: original_pkg_path, signing_key: signing_key) + require_relative "release" + output_path = Release.new(@root).run(worktree_path, "build", root: original_pkg_path, signing_key: signing_key) output_path ensure @@ -253,16 +255,16 @@ def build_gem_in_worktree(root: "pkg", signing_key: nil) end end - # Create a release branch, add the version file, and commit the changes. - # @parameter version_path [String] The path to the version file that was updated. - # @parameter message [String] The commit message to use. + # Create a release branch before generating release changes. + # @parameter version [String] The proposed release version. # @returns [String] The name of the created branch. - def create_release_branch(version_path, message: "Bump version.") - branch_name = "release-v#{@gemspec.version}" + def create_release_branch(version:) + guard_clean + raise "Release preparation requires a branch checkout." unless current_branch + branch_name = "release-v#{version}" + raise "Release tag v#{version} already exists." unless readlines("git", "tag", "--list", "v#{version}", chdir: @root).empty? system("git", "checkout", "-b", branch_name, chdir: @root) - system("git", "add", version_path, chdir: @root) - system("git", "commit", "-m", message, chdir: @root) return branch_name end @@ -270,10 +272,25 @@ def create_release_branch(version_path, message: "Bump version.") # Commit version changes to the current branch. # @parameter message [String] The commit message to use. def commit_version_changes(message: "Bump version.") + guard_release_changes system("git", "add", "--all", chdir: @root) system("git", "commit", "-m", message, chdir: @root) end + # Reject generated package output and private keys before staging release changes. + def guard_release_changes + paths = readlines("git", "diff", "--name-only", "--diff-filter=ACM", "-z", "HEAD", chdir: @root).join.split("\0") + paths.concat(readlines("git", "ls-files", "--others", "--exclude-standard", "-z", chdir: @root).join.split("\0")) + paths.uniq.each do |path| + raise "Release changes include build output: #{path}" if path.start_with?("pkg/") || path.end_with?(".gem") + absolute = File.expand_path(path, @root) + next unless File.file?(absolute) && !File.symlink?(absolute) + if File.binread(absolute).match?(/^-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/) + raise "Release changes include a private key: #{path}" + end + end + end + # Fetch remote tags and create a release tag for the specified version. # @parameter tag [Boolean] Whether to tag the release. # @parameter version [String] The version to tag. @@ -298,13 +315,13 @@ def delete_git_tag(tag_name) # Push changes and tags to the remote repository. # @parameter current_branch [String | Nil] The current branch name, or nil if not on a branch. - def push_release(current_branch: nil) + def push_release(current_branch: nil, tag: nil) # If we are on a branch, push, otherwise just push the tags (assuming shallow checkout): if current_branch system("git", "push", chdir: @root) end - system("git", "push", "--tags", chdir: @root) + system("git", "push", "origin", "refs/tags/#{tag}", chdir: @root) if tag end # Figure out if there is a current branch, if not, return `nil`. @@ -330,7 +347,7 @@ def find_gemspec(glob = "*.gemspec") end if path = paths.first - return ::Gem::Specification.load(File.expand_path(path, @root)) + return Dir.chdir(@root){::Gem::Specification.load(File.expand_path(path, @root))} end end end diff --git a/lib/bake/gem/release.rb b/lib/bake/gem/release.rb new file mode 100644 index 0000000..8a118c5 --- /dev/null +++ b/lib/bake/gem/release.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "helper" +require "json" +require "tmpdir" +require "rbconfig" + +module Bake + module Gem + # Regenerates release content from its base without modifying the checkout. + class Release + include Shell + + # Supported stable version increments. + BUMPS = {"patch" => [nil, nil, 1], "minor" => [nil, 1, 0], "major" => [1, 0, 0]}.freeze + + # @parameter root [String] The repository containing the commits to compare. + def initialize(root) + @root = File.expand_path(root) + end + + # Resolve a commit before passing it to other Git commands. + def resolve(reference) + git("rev-parse", "--verify", "--end-of-options", "#{reference}^{commit}").strip + end + + # Yield a temporary detached worktree and remove it even on failure. + def worktree(reference) + commit = resolve(reference) + Dir.mktmpdir("bake-gem-release-") do |directory| + path = File.join(directory, "source") + system("git", "worktree", "add", "--detach", path, commit, chdir: @root, out: File::NULL) + begin + yield path + ensure + system("git", "worktree", "remove", "--force", path, chdir: @root, out: File::NULL) + end + end + end + + # Run repository code in a fresh interpreter, avoiding cached version constants. + def run(path, action, **options) + Dir.mktmpdir("bake-gem-result-") do |directory| + result = File.join(directory, "result.json") + request = {action: action, options: options, result: result, load_path: $LOAD_PATH, gems: ::Gem.loaded_specs.values.map(&:full_gem_path)} + request_path = File.join(directory, "request.json") + File.write(request_path, JSON.generate(request)) + worker = File.expand_path("release/worker.rb", __dir__) + system({"RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil}, RbConfig.ruby, worker, request_path, chdir: path) + JSON.parse(File.read(result), symbolize_names: true) + end + end + + # Inspect a committed gem in isolation. + def metadata(reference) + worktree(reference) {|path| run(path, "metadata")} + end + + # Validate an ordinary patch, minor, or major transition. + def bump(previous, target) + unless previous.match?(/\A\d+\.\d+\.\d+\z/) && target.match?(/\A\d+\.\d+\.\d+\z/) + raise "Release validation supports stable three-part versions only." + end + BUMPS.each do |name, increment| + version = Version.new(previous.split(".").map(&:to_i), nil).increment(increment) + return name if version.join == target + end + raise "Unsupported release transition: #{previous} -> #{target}." + end + + # Compare an independently generated release tree to the candidate, including added and deleted files. + # @parameter base [String] Current target commit, or the merged commit's first parent when publishing. + # @parameter candidate [String] Proposed or merged release commit. + # @parameter optional [Boolean] Allow ordinary PRs which do not change the version. + def validate(base:, candidate: "HEAD", optional: false) + base = resolve(base) + candidate = resolve(candidate) + proposed = metadata(candidate) + worktree(base) do |path| + previous = run(path, "metadata") + raise "Release changes the gem name." unless proposed[:name] == previous[:name] + return nil if optional && proposed[:version] == previous[:version] + increment = bump(previous[:version], proposed[:version]) + generated = run(path, "prepare", bump: BUMPS.fetch(increment)) + raise "Generated version does not match proposal." unless generated[:version] == proposed[:version] + system("git", "add", "--all", chdir: path) + expected = readlines("git", "write-tree", chdir: path).join.strip + diff = git("diff", "--no-ext-diff", "--no-textconv", candidate, expected, "--") + raise "Release content is stale or contains unrelated changes. Expected changes:\n#{diff}" unless diff.empty? + return proposed.merge(base: base, commit: candidate, bump: increment) + end + end + + private + + def git(*arguments) + readlines("git", *arguments, chdir: @root).join + end + end + end +end diff --git a/lib/bake/gem/release/worker.rb b/lib/bake/gem/release/worker.rb new file mode 100644 index 0000000..bbbb4f6 --- /dev/null +++ b/lib/bake/gem/release/worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +# Internal subprocess entry point. Repository files are executable code; callers +# must keep PR validation separate from jobs with publishing credentials. +require "json" +request = JSON.parse(File.read(ARGV.fetch(0)), symbolize_names: true) +$LOAD_PATH.replace(request.fetch(:load_path)) +require_relative "../helper" +options = request.fetch(:options) +helper = Bake::Gem::Helper.new(Dir.pwd) +raise "No gemspec found." unless helper.gemspec + +result = case request.fetch(:action) +when "metadata" + {name: helper.gemspec.name, version: helper.gemspec.version.to_s, version_path: helper.version_path} +when "build" + helper.build_gem(**options) +when "prepare" + require "bake/context" + registry = Bake::Registry::Aggregate.new + request.fetch(:gems).each{|path| registry.append_path(path)} + registry.append_path(File.expand_path("../../../..", __dir__)) + registry.append_path(Dir.pwd) + registry.append_bakefile(File.expand_path("bake.rb")) if File.file?("bake.rb") + context = Bake::Context.new(registry, Dir.pwd) + context.bakefile + prepared = context.lookup("gem:release:version:increment").call(options.fetch(:bump)) + helper.guard_release_changes + prepared +else + raise "Unknown release worker action." +end + +File.write(request.fetch(:result), JSON.generate(result)) diff --git a/test/bake/gem/helper.rb b/test/bake/gem/helper.rb index 0354c6c..7223ba4 100644 --- a/test/bake/gem/helper.rb +++ b/test/bake/gem/helper.rb @@ -58,6 +58,7 @@ def around @helper = subject.new(root) system("git", "init", chdir: root) + system("git", "config", "core.hooksPath", File::NULL, chdir: root) system("git", "config", "user.email", "test@test.com", chdir: root) system("git", "config", "user.name", "Test User", chdir: root) @@ -143,6 +144,9 @@ def around # Verify the gem was built in the original location, not worktree expect(package_path).to be(:start_with?, helper.root) + package = Gem::Package.new(package_path) + expect(package.contents).to be(:include?, "lib/test_gem.rb") + expect(package.contents).not.to be(:include?, "lib/bake/gem/helper.rb") end end diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb new file mode 100644 index 0000000..78a7ad2 --- /dev/null +++ b/test/bake/gem/release.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/helper" +require "bake/gem/release" +require "bake/context" +require "sus/fixtures/console/null_logger" +require "open3" + +RELEASE_TASK_ROOT = File.expand_path("../../..", __dir__) + +describe Bake::Gem::Release do + include Sus::Fixtures::Console::NullLogger + + def git(*arguments) + output, status = Open3.capture2e("git", *arguments, chdir: @root) + raise output unless status.success? + output.strip + end + + def write(path, content) + FileUtils.mkdir_p(File.dirname(File.join(@root, path))) + File.write(File.join(@root, path), content) + end + + def commit(message) + git("add", "--all") + git("commit", "-m", message) + git("rev-parse", "HEAD") + end + + def prepare + Dir.chdir(@root) do + registry = Bake::Registry::Aggregate.new + registry.append_path(RELEASE_TASK_ROOT) + registry.append_bakefile(File.join(@root, "bake.rb")) + Bake::Context.new(registry, @root).lookup("gem:release:branch:patch").call + end + end + + def around + Dir.mktmpdir do |root| + @root = root + git("init", "--initial-branch=main") + git("config", "core.hooksPath", File::NULL) + git("config", "user.name", "Release Test") + git("config", "user.email", "test@example.com") + write("lib/example/version.rb", "module Example; VERSION = \"1.0.0\"; end\n") + write("example.gemspec", <<~RUBY) + require_relative "lib/example/version" + Gem::Specification.new do |spec| + spec.name = "example" + spec.version = Example::VERSION + spec.summary = "Example" + spec.authors = ["Test"] + spec.files = Dir.glob("lib/**/*") + end + RUBY + write("changes.md", "First change\n") + write("obsolete.md", "Removed by hook\n") + write("bake.rb", <<~RUBY) + def after_gem_release_version_increment(version) + File.write("releases.md", version.join + "\\n" + File.read("changes.md")) + File.delete("obsolete.md") + end + RUBY + @base = commit("Initial source") + @release = subject.new(@root) + yield + ensure + Object.send(:remove_const, :Example) if Object.const_defined?(:Example) + end + end + + it "creates a local branch and commits every hook output without tags or remotes" do + result = prepare + expect(result[:branch]).to be == "release-v1.0.1" + expect(result[:version_path]).to be == "lib/example/version.rb" + expect(git("status", "--porcelain")).to be == "" + expect(git("tag")).to be == "" + expect(git("show", "HEAD:releases.md")).to be == "1.0.1\nFirst change" + expect(File).not.to be(:exist?, File.join(@root, "obsolete.md")) + expect(@release.validate(base: @base)[:version]).to be == "1.0.1" + end + + it "rejects a dirty checkout before changing branch or version" do + write("unrelated.txt", "Uncommitted") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /uncommited/) + expect(git("branch", "--show-current")).to be == "main" + expect(File.read(File.join(@root, "lib/example/version.rb"))).to be(:include?, "1.0.0") + end + + it "rejects branch collisions before modifying files" do + git("branch", "release-v1.0.1") + expect{prepare}.to raise_exception(Bake::Gem::CommandExecutionError) + expect(git("status", "--porcelain")).to be == "" + end + + it "rejects detached preparation before modifying files" do + git("checkout", "--detach") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /branch checkout/) + expect(git("status", "--porcelain")).to be == "" + end + + it "rejects a missing version constant without creating a branch" do + write("lib/example/version.rb", "module Example; VERSION = [1, 0, 0].join(\".\"); end\n") + commit("Change version representation") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /Could not find version number/) + expect(git("branch", "--show-current")).to be == "main" + end + + it "leaves failed hook changes available without committing or publishing" do + write("bake.rb", "def after_gem_release_version_increment(version); File.write(\"partial.md\", \"Partial\"); raise \"Hook failed\"; end\n") + base = commit("Broken hook") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /Hook failed/) + expect(git("rev-parse", "HEAD")).to be == base + expect(File).to be(:exist?, File.join(@root, "partial.md")) + end + + it "rejects private keys generated by hooks before staging them" do + write("bake.rb", "def after_gem_release_version_increment(version); File.write(\"release.pem\", \"-----BEGIN PRIVATE KEY-----\"); end\n") + base = commit("Unsafe hook") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /private key/) + expect(git("rev-parse", "HEAD")).to be == base + expect(git("diff", "--cached", "--name-only")).to be == "" + end + + it "accepts an updated base when regenerated content is unchanged" do + prepare + git("checkout", "main") + write("unrelated.txt", "New main content") + base = commit("Independent change") + git("checkout", "release-v1.0.1") + git("rebase", "main") + expect(@release.validate(base: base)[:version]).to be == "1.0.1" + end + + it "rejects stale notes after a successful rebase" do + prepare + git("checkout", "main") + write("changes.md", "First change\nNew change\n") + base = commit("More release notes") + git("checkout", "release-v1.0.1") + git("rebase", "main") + expect{@release.validate(base: base)}.to raise_exception(RuntimeError, message: be =~ /New change/) + expect(git("status", "--porcelain")).to be == "" + end + + it "rejects unrelated additions and deletions in a release" do + prepare + write("unexpected.txt", "Surprise") + commit("Unexpected file") + expect{@release.validate(base: @base)}.to raise_exception(RuntimeError, message: be =~ /unexpected.txt/) + end + + it "validates a squash commit against its first parent even after main advances" do + prepare + git("checkout", "main") + git("merge", "--squash", "release-v1.0.1") + merged = commit("Release version 1.0.1") + write("later.txt", "Later development") + commit("Continue development") + expect(@release.validate(base: "#{merged}^1", candidate: merged)[:commit]).to be == merged + end + + it "skips ordinary PRs only when explicitly requested" do + expect(@release.validate(base: @base, optional: true)).to be_nil + expect{@release.validate(base: @base)}.to raise_exception(RuntimeError, message: be =~ /Unsupported release transition/) + end +end From f182405d42f393a2f95d283beffb9069a898d865 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 14:42:21 +1200 Subject: [PATCH 2/8] Use releases/vVERSION for release branches --- context/getting-started.md | 4 ++-- guides/getting-started/readme.md | 4 ++-- lib/bake/gem/helper.rb | 2 +- test/bake/gem/release.rb | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 26b883b..bd8143d 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -84,7 +84,7 @@ $ bake gem:release:branch:patch # or minor/major This will: - Require a clean checkout on a branch -- Create a new branch named `release-v[new-version]` before modifying files +- Create a new branch named `releases/v[new-version]` before modifying files - Bump the gem version - Run `after_gem_release_version_increment` and commit all changes, including added and deleted documentation @@ -188,7 +188,7 @@ $ bake gem:release:patch ``` bash # Create release branch $ bake gem:release:branch:minor -# Creates branch: release-v1.3.0 +# Creates branch: releases/v1.3.0 # Commits the version bump and release-hook output # Leaves the branch local for inspection diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 26b883b..bd8143d 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -84,7 +84,7 @@ $ bake gem:release:branch:patch # or minor/major This will: - Require a clean checkout on a branch -- Create a new branch named `release-v[new-version]` before modifying files +- Create a new branch named `releases/v[new-version]` before modifying files - Bump the gem version - Run `after_gem_release_version_increment` and commit all changes, including added and deleted documentation @@ -188,7 +188,7 @@ $ bake gem:release:patch ``` bash # Create release branch $ bake gem:release:branch:minor -# Creates branch: release-v1.3.0 +# Creates branch: releases/v1.3.0 # Commits the version bump and release-hook output # Leaves the branch local for inspection diff --git a/lib/bake/gem/helper.rb b/lib/bake/gem/helper.rb index 0eff21e..08465c7 100644 --- a/lib/bake/gem/helper.rb +++ b/lib/bake/gem/helper.rb @@ -261,7 +261,7 @@ def build_gem_in_worktree(root: "pkg", signing_key: nil) def create_release_branch(version:) guard_clean raise "Release preparation requires a branch checkout." unless current_branch - branch_name = "release-v#{version}" + branch_name = "releases/v#{version}" raise "Release tag v#{version} already exists." unless readlines("git", "tag", "--list", "v#{version}", chdir: @root).empty? system("git", "checkout", "-b", branch_name, chdir: @root) diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index 78a7ad2..dd03306 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -76,7 +76,7 @@ def after_gem_release_version_increment(version) it "creates a local branch and commits every hook output without tags or remotes" do result = prepare - expect(result[:branch]).to be == "release-v1.0.1" + expect(result[:branch]).to be == "releases/v1.0.1" expect(result[:version_path]).to be == "lib/example/version.rb" expect(git("status", "--porcelain")).to be == "" expect(git("tag")).to be == "" @@ -93,7 +93,7 @@ def after_gem_release_version_increment(version) end it "rejects branch collisions before modifying files" do - git("branch", "release-v1.0.1") + git("branch", "releases/v1.0.1") expect{prepare}.to raise_exception(Bake::Gem::CommandExecutionError) expect(git("status", "--porcelain")).to be == "" end @@ -132,7 +132,7 @@ def after_gem_release_version_increment(version) git("checkout", "main") write("unrelated.txt", "New main content") base = commit("Independent change") - git("checkout", "release-v1.0.1") + git("checkout", "releases/v1.0.1") git("rebase", "main") expect(@release.validate(base: base)[:version]).to be == "1.0.1" end @@ -142,7 +142,7 @@ def after_gem_release_version_increment(version) git("checkout", "main") write("changes.md", "First change\nNew change\n") base = commit("More release notes") - git("checkout", "release-v1.0.1") + git("checkout", "releases/v1.0.1") git("rebase", "main") expect{@release.validate(base: base)}.to raise_exception(RuntimeError, message: be =~ /New change/) expect(git("status", "--porcelain")).to be == "" @@ -158,7 +158,7 @@ def after_gem_release_version_increment(version) it "validates a squash commit against its first parent even after main advances" do prepare git("checkout", "main") - git("merge", "--squash", "release-v1.0.1") + git("merge", "--squash", "releases/v1.0.1") merged = commit("Release version 1.0.1") write("later.txt", "Later development") commit("Continue development") From 0c134514fa2b7a20e2a7c7f1502243baf08eef22 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 14:53:45 +1200 Subject: [PATCH 3/8] Run worktree release operations as Bake tasks --- bake/gem.rb | 14 +++++++++++- bake/gem/release/version.rb | 1 + context/getting-started.md | 11 ++++++++++ guides/getting-started/readme.md | 11 ++++++++++ lib/bake/gem/helper.rb | 4 ++-- lib/bake/gem/release.rb | 35 +++++++++++++++++++++--------- lib/bake/gem/release/worker.rb | 37 -------------------------------- test/bake/gem/release.rb | 13 +++++++++++ 8 files changed, 76 insertions(+), 50 deletions(-) delete mode 100644 lib/bake/gem/release/worker.rb diff --git a/bake/gem.rb b/bake/gem.rb index abda0ae..093ccff 100644 --- a/bake/gem.rb +++ b/bake/gem.rb @@ -21,10 +21,22 @@ def files @helper.gemspec.files end +# Inspect the gem name, version, and version file in the current checkout. +def metadata + gemspec = @helper.gemspec + raise "No gemspec found." unless gemspec + + return {name: gemspec.name, version: gemspec.version.to_s, version_path: @helper.version_path} +end + # Build the gem into the pkg directory. # @parameter root [String] The root directory to build the gem into. Defaults to `pkg`. -# @parameter signing_key [Boolean] Whether to use a signing key. +# @parameter signing_key [String | Boolean | Nil] A signing key path, true to require signing, or false to disable signing. def build(root: "pkg", signing_key: nil) + # Accept boolean command line options while preserving signing key paths: + signing_key = true if signing_key == "true" + signing_key = false if signing_key == "false" + @helper.build_gem(root: root, signing_key: signing_key) end diff --git a/bake/gem/release/version.rb b/bake/gem/release/version.rb index 194a71c..a30722d 100644 --- a/bake/gem/release/version.rb +++ b/bake/gem/release/version.rb @@ -36,6 +36,7 @@ def increment(bump, message: "Bump version.") after_increment(version) end raise "Could not find version number!" unless version_path + helper.guard_release_changes return { version: gemspec.version, diff --git a/context/getting-started.md b/context/getting-started.md index bd8143d..f945a8d 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -116,6 +116,9 @@ $ bake gem:install # List files that will be included in the gem $ bake gem:files +# Inspect the gem name, version, and version file as JSON +$ bake gem:metadata output format=json + # Build without signing $ bake gem:build signing_key=false ``` @@ -130,6 +133,8 @@ The tool automatically prevents consecutive version bumps by checking the last c ### Clean Worktree Building Gems are built in isolated git worktrees to ensure the build environment exactly matches your committed code, preventing issues with uncommitted changes affecting the build. +Builds and release validation run Bake tasks in fresh Ruby processes so version constants and hook state come from each checkout. + ### Repository Cleanliness Check Before any release operation, the tool ensures your repository has no uncommitted changes. @@ -144,6 +149,12 @@ spec.signing_key = "path/to/private_key.pem" spec.cert_chain = ["path/to/certificate.pem"] ``` +To supply a signing key when building: + +``` bash +$ bake gem:build signing_key=/path/to/private_key.pem +``` + Or disable signing explicitly: ``` bash diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index bd8143d..f945a8d 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -116,6 +116,9 @@ $ bake gem:install # List files that will be included in the gem $ bake gem:files +# Inspect the gem name, version, and version file as JSON +$ bake gem:metadata output format=json + # Build without signing $ bake gem:build signing_key=false ``` @@ -130,6 +133,8 @@ The tool automatically prevents consecutive version bumps by checking the last c ### Clean Worktree Building Gems are built in isolated git worktrees to ensure the build environment exactly matches your committed code, preventing issues with uncommitted changes affecting the build. +Builds and release validation run Bake tasks in fresh Ruby processes so version constants and hook state come from each checkout. + ### Repository Cleanliness Check Before any release operation, the tool ensures your repository has no uncommitted changes. @@ -144,6 +149,12 @@ spec.signing_key = "path/to/private_key.pem" spec.cert_chain = ["path/to/certificate.pem"] ``` +To supply a signing key when building: + +``` bash +$ bake gem:build signing_key=/path/to/private_key.pem +``` + Or disable signing explicitly: ``` bash diff --git a/lib/bake/gem/helper.rb b/lib/bake/gem/helper.rb index 08465c7..c94b9ae 100644 --- a/lib/bake/gem/helper.rb +++ b/lib/bake/gem/helper.rb @@ -244,9 +244,9 @@ def build_gem_in_worktree(root: "pkg", signing_key: nil) raise "Failed to create git worktree. Make sure you have at least one commit in the repository." end - # Create helper for the worktree + # Build using the worktree's gemspec in a fresh interpreter: require_relative "release" - output_path = Release.new(@root).run(worktree_path, "build", root: original_pkg_path, signing_key: signing_key) + output_path = Release.new(@root).bake(worktree_path, "gem:build", root: original_pkg_path, signing_key: signing_key) output_path ensure diff --git a/lib/bake/gem/release.rb b/lib/bake/gem/release.rb index 8a118c5..00ab195 100644 --- a/lib/bake/gem/release.rb +++ b/lib/bake/gem/release.rb @@ -41,22 +41,37 @@ def worktree(reference) end end - # Run repository code in a fresh interpreter, avoiding cached version constants. - def run(path, action, **options) + # Run Bake tasks in a fresh interpreter, avoiding cached version constants. + # @parameter path [String] The checkout in which to run the tasks. + # @parameter arguments [Array(String)] Task names and command line arguments. + # @parameter options [Hash] Task options; nil values use the task defaults. + def bake(path, *arguments, **options) + # Reuse the caller's dependencies and task paths without loading its gemspec: + paths = ::Gem.loaded_specs.values.map(&:full_gem_path) + paths << File.expand_path("../../..", __dir__) + script = <<~RUBY + require "bake/context" + registry = Bake::Registry::Aggregate.new + #{paths.inspect}.each{|path| registry.append_path(path)} + registry.append_path(Dir.pwd) + registry.append_bakefile(File.expand_path("bake.rb")) if File.file?("bake.rb") + context = Bake::Context.new(registry, Dir.pwd) + context.bakefile + context.call(*ARGV) + RUBY + Dir.mktmpdir("bake-gem-result-") do |directory| result = File.join(directory, "result.json") - request = {action: action, options: options, result: result, load_path: $LOAD_PATH, gems: ::Gem.loaded_specs.values.map(&:full_gem_path)} - request_path = File.join(directory, "request.json") - File.write(request_path, JSON.generate(request)) - worker = File.expand_path("release/worker.rb", __dir__) - system({"RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil}, RbConfig.ruby, worker, request_path, chdir: path) + options.each{|key, value| arguments << "#{key}=#{value}" unless value.nil?} + arguments.concat(["output", "file=#{result}", "format=json"]) + system({"RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil}, RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-e", script, "--", *arguments, chdir: path) JSON.parse(File.read(result), symbolize_names: true) end end # Inspect a committed gem in isolation. def metadata(reference) - worktree(reference) {|path| run(path, "metadata")} + worktree(reference) {|path| bake(path, "gem:metadata")} end # Validate an ordinary patch, minor, or major transition. @@ -80,11 +95,11 @@ def validate(base:, candidate: "HEAD", optional: false) candidate = resolve(candidate) proposed = metadata(candidate) worktree(base) do |path| - previous = run(path, "metadata") + previous = bake(path, "gem:metadata") raise "Release changes the gem name." unless proposed[:name] == previous[:name] return nil if optional && proposed[:version] == previous[:version] increment = bump(previous[:version], proposed[:version]) - generated = run(path, "prepare", bump: BUMPS.fetch(increment)) + generated = bake(path, "gem:release:version:increment", BUMPS.fetch(increment).join(",")) raise "Generated version does not match proposal." unless generated[:version] == proposed[:version] system("git", "add", "--all", chdir: path) expected = readlines("git", "write-tree", chdir: path).join.strip diff --git a/lib/bake/gem/release/worker.rb b/lib/bake/gem/release/worker.rb deleted file mode 100644 index bbbb4f6..0000000 --- a/lib/bake/gem/release/worker.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -# Internal subprocess entry point. Repository files are executable code; callers -# must keep PR validation separate from jobs with publishing credentials. -require "json" -request = JSON.parse(File.read(ARGV.fetch(0)), symbolize_names: true) -$LOAD_PATH.replace(request.fetch(:load_path)) -require_relative "../helper" -options = request.fetch(:options) -helper = Bake::Gem::Helper.new(Dir.pwd) -raise "No gemspec found." unless helper.gemspec - -result = case request.fetch(:action) -when "metadata" - {name: helper.gemspec.name, version: helper.gemspec.version.to_s, version_path: helper.version_path} -when "build" - helper.build_gem(**options) -when "prepare" - require "bake/context" - registry = Bake::Registry::Aggregate.new - request.fetch(:gems).each{|path| registry.append_path(path)} - registry.append_path(File.expand_path("../../../..", __dir__)) - registry.append_path(Dir.pwd) - registry.append_bakefile(File.expand_path("bake.rb")) if File.file?("bake.rb") - context = Bake::Context.new(registry, Dir.pwd) - context.bakefile - prepared = context.lookup("gem:release:version:increment").call(options.fetch(:bump)) - helper.guard_release_changes - prepared -else - raise "Unknown release worker action." -end - -File.write(request.fetch(:result), JSON.generate(result)) diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index dd03306..bed35cf 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -85,6 +85,19 @@ def after_gem_release_version_increment(version) expect(@release.validate(base: @base)[:version]).to be == "1.0.1" end + it "builds the committed version even when the caller has loaded the old version" do + prepare + expect(Example::VERSION).to be == "1.0.0" + + @release.worktree("HEAD") do |path| + package_path = @release.bake(path, "gem:build", root: File.join(@root, "packages with spaces"), signing_key: false) + package = Gem::Package.new(package_path) + package.extract_files(File.join(@root, "extracted")) + expect(package.spec.version.to_s).to be == "1.0.1" + expect(File.read(File.join(@root, "extracted/lib/example/version.rb"))).to be(:include?, 'VERSION = "1.0.1"') + end + end + it "rejects a dirty checkout before changing branch or version" do write("unrelated.txt", "Uncommitted") expect{prepare}.to raise_exception(RuntimeError, message: be =~ /uncommited/) From 0884d25c311209ed4658a6014d769e4509ff5875 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 15:13:28 +1200 Subject: [PATCH 4/8] Use the caller working directory for gem helpers --- context/getting-started.md | 4 +++- guides/getting-started/readme.md | 4 +++- lib/bake/gem/helper.rb | 12 ++++++------ test/bake/gem/helper.rb | 25 ++++++++++++++----------- test/bake/gem/release.rb | 5 +++-- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index f945a8d..0bbd9a5 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -20,6 +20,8 @@ end ## Usage +Run Bake tasks from the gem project's root directory. When using `Bake::Gem::Helper` directly, construct and use it with that directory as the process's working directory. Gemspec evaluation and packaging resolve relative paths there; the helper does not change the working directory. + Before using `bake-gem`, ensure you have: 1. A properly configured `gemspec` file in your project root @@ -133,7 +135,7 @@ The tool automatically prevents consecutive version bumps by checking the last c ### Clean Worktree Building Gems are built in isolated git worktrees to ensure the build environment exactly matches your committed code, preventing issues with uncommitted changes affecting the build. -Builds and release validation run Bake tasks in fresh Ruby processes so version constants and hook state come from each checkout. +Worktree builds and release validation run Bake tasks in fresh Ruby processes launched with the checkout as their working directory, so version constants and hook state come from each checkout. The parent process's working directory is unchanged. ### Repository Cleanliness Check Before any release operation, the tool ensures your repository has no uncommitted changes. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index f945a8d..0bbd9a5 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -20,6 +20,8 @@ end ## Usage +Run Bake tasks from the gem project's root directory. When using `Bake::Gem::Helper` directly, construct and use it with that directory as the process's working directory. Gemspec evaluation and packaging resolve relative paths there; the helper does not change the working directory. + Before using `bake-gem`, ensure you have: 1. A properly configured `gemspec` file in your project root @@ -133,7 +135,7 @@ The tool automatically prevents consecutive version bumps by checking the last c ### Clean Worktree Building Gems are built in isolated git worktrees to ensure the build environment exactly matches your committed code, preventing issues with uncommitted changes affecting the build. -Builds and release validation run Bake tasks in fresh Ruby processes so version constants and hook state come from each checkout. +Worktree builds and release validation run Bake tasks in fresh Ruby processes launched with the checkout as their working directory, so version constants and hook state come from each checkout. The parent process's working directory is unchanged. ### Repository Cleanliness Check Before any release operation, the tool ensures your repository has no uncommitted changes. diff --git a/lib/bake/gem/helper.rb b/lib/bake/gem/helper.rb index c94b9ae..dab3140 100644 --- a/lib/bake/gem/helper.rb +++ b/lib/bake/gem/helper.rb @@ -82,14 +82,16 @@ def increment(bump) end # Helper class for performing gem-related operations like building, installing, and publishing gems. + # The process must already be in the gem project's root directory when constructing and using a helper. + # Gemspec evaluation and packaging resolve relative paths against that working directory; the helper does not change it. class Helper include Shell # Initialize a new helper with the specified root directory and optional gemspec. - # @parameter root [String] The root directory of the gem project. + # @parameter root [String] The root directory of the gem project, which must also be the process's working directory. # @parameter gemspec [Gem::Specification | Nil] The gemspec to use, or nil to find it automatically. def initialize(root = Dir.pwd, gemspec: nil) - @root = File.expand_path(root) + @root = root @gemspec = gemspec || find_gemspec end @@ -208,9 +210,7 @@ def build_gem(root: "pkg", signing_key: nil) raise ArgumentError, "Signing key is required for signing the gem, but none was specified by the gemspec." end - Dir.chdir(@root) do - ::Gem::Package.build(@gemspec, false, false, output_path) - end + ::Gem::Package.build(@gemspec, false, false, output_path) end # Install the gem using the `gem install` command. @@ -347,7 +347,7 @@ def find_gemspec(glob = "*.gemspec") end if path = paths.first - return Dir.chdir(@root){::Gem::Specification.load(File.expand_path(path, @root))} + return ::Gem::Specification.load(File.expand_path(path, @root)) end end end diff --git a/test/bake/gem/helper.rb b/test/bake/gem/helper.rb index 7223ba4..5ff5313 100644 --- a/test/bake/gem/helper.rb +++ b/test/bake/gem/helper.rb @@ -6,8 +6,7 @@ require "bake/gem/helper" require "sus/fixtures/console/null_logger" - -require "tmpdir" +require "sus/fixtures/temporary_directory_context" describe Bake::Gem::Helper do let(:helper) {subject.new} @@ -51,18 +50,22 @@ end with "repository" do + include Sus::Fixtures::TemporaryDirectoryContext + let(:helper) {@helper} def around - Dir.mktmpdir do |root| - @helper = subject.new(root) - - system("git", "init", chdir: root) - system("git", "config", "core.hooksPath", File::NULL, chdir: root) - system("git", "config", "user.email", "test@test.com", chdir: root) - system("git", "config", "user.name", "Test User", chdir: root) - - yield + super do + Dir.chdir(root) do + @helper = subject.new(root) + + system("git", "init", chdir: root) + system("git", "config", "core.hooksPath", File::NULL, chdir: root) + system("git", "config", "user.email", "test@test.com", chdir: root) + system("git", "config", "user.name", "Test User", chdir: root) + + yield + end end end diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index bed35cf..f745954 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -7,12 +7,14 @@ require "bake/gem/release" require "bake/context" require "sus/fixtures/console/null_logger" +require "sus/fixtures/temporary_directory_context" require "open3" RELEASE_TASK_ROOT = File.expand_path("../../..", __dir__) describe Bake::Gem::Release do include Sus::Fixtures::Console::NullLogger + include Sus::Fixtures::TemporaryDirectoryContext def git(*arguments) output, status = Open3.capture2e("git", *arguments, chdir: @root) @@ -41,8 +43,7 @@ def prepare end def around - Dir.mktmpdir do |root| - @root = root + super do git("init", "--initial-branch=main") git("config", "core.hooksPath", File::NULL) git("config", "user.name", "Release Test") From 414fb4450c24aa1e42941476cedcf5772032cf77 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 15:21:09 +1200 Subject: [PATCH 5/8] Isolate repository test operations in subprocesses --- fixtures/bake/gem/ruby_context.rb | 33 +++++++++++++++ test/bake/gem/helper.rb | 70 ++++++++++++++++--------------- test/bake/gem/release.rb | 41 +++++++++--------- 3 files changed, 91 insertions(+), 53 deletions(-) create mode 100644 fixtures/bake/gem/ruby_context.rb diff --git a/fixtures/bake/gem/ruby_context.rb b/fixtures/bake/gem/ruby_context.rb new file mode 100644 index 0000000..b52654b --- /dev/null +++ b/fixtures/bake/gem/ruby_context.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/shell" +require "json" +require "open3" +require "rbconfig" + +module Bake + module Gem + # Runs repository code without changing the test process's directory or constants. + module RubyContext + def ruby(source) + script = <<~RUBY + output = $stdout.dup + $stdout.reopen($stderr) + result = begin + #{source} + end + output.write(JSON.generate(result)) + RUBY + + # Load test dependencies from this project while executing in the fixture: + environment = {"RUBYOPT" => nil, "BUNDLE_GEMFILE" => File.expand_path("../../../gems.rb", __dir__)} + output, errors, status = Open3.capture3(environment, RbConfig.ruby, "-rbundler/setup", "-rjson", "-e", script, chdir: root) + raise CommandExecutionError.new(errors, status) unless status.success? + JSON.parse(output, symbolize_names: true) + end + end + end +end diff --git a/test/bake/gem/helper.rb b/test/bake/gem/helper.rb index 5ff5313..4b1390b 100644 --- a/test/bake/gem/helper.rb +++ b/test/bake/gem/helper.rb @@ -7,6 +7,7 @@ require "bake/gem/helper" require "sus/fixtures/console/null_logger" require "sus/fixtures/temporary_directory_context" +require "bake/gem/ruby_context" describe Bake::Gem::Helper do let(:helper) {subject.new} @@ -51,78 +52,81 @@ with "repository" do include Sus::Fixtures::TemporaryDirectoryContext + include Bake::Gem::RubyContext - let(:helper) {@helper} + def run_helper(source) + ruby(<<~RUBY) + require "bake/gem/helper" + helper = Bake::Gem::Helper.new + #{source} + RUBY + end def around super do - Dir.chdir(root) do - @helper = subject.new(root) - - system("git", "init", chdir: root) - system("git", "config", "core.hooksPath", File::NULL, chdir: root) - system("git", "config", "user.email", "test@test.com", chdir: root) - system("git", "config", "user.name", "Test User", chdir: root) - - yield - end + system("git", "init", chdir: root) + system("git", "config", "core.hooksPath", File::NULL, chdir: root) + system("git", "config", "user.email", "test@test.com", chdir: root) + system("git", "config", "user.name", "Test User", chdir: root) + + yield end end it "can update the version" do - version_path = File.expand_path("version.rb", helper.root) + version_path = File.expand_path("version.rb", root) File.write(version_path, "VERSION = '0.0.0'\n") - helper.update_version([1, 1, 1], version_path) + run_helper('helper.update_version([1, 1, 1], "version.rb")') expect(File.read(version_path)).to be == "VERSION = '1.1.1'\n" end it "prevents consecutive version bumps" do - version_path = File.expand_path("version.rb", helper.root) + version_path = File.expand_path("version.rb", root) File.write(version_path, "VERSION = '0.0.0'\n") # Create initial commit - system("git", "add", ".", chdir: helper.root) - system("git", "commit", "-m", "Initial version", chdir: helper.root) + system("git", "add", ".", chdir: root) + system("git", "commit", "-m", "Initial version", chdir: root) # Create a version bump commit - system("git", "commit", "--allow-empty", "-m", "Bump patch version.", chdir: helper.root) + system("git", "commit", "--allow-empty", "-m", "Bump patch version.", chdir: root) # Attempting another version bump should fail - expect{helper.update_version([0, 0, 1], version_path)}.to raise_exception(RuntimeError, message: be =~ /Last commit appears to be a version bump/) + expect{run_helper('helper.update_version([0, 0, 1], "version.rb")')}.to raise_exception(Bake::Gem::CommandExecutionError, message: be =~ /Last commit appears to be a version bump/) end it "allows version bump when there are no commits (handles exit code 128)" do - version_path = File.expand_path("version.rb", helper.root) + version_path = File.expand_path("version.rb", root) File.write(version_path, "VERSION = '0.0.0'\n") # Don't create any commits, so git log will exit with 128 # This should not raise an error and should allow version bump - expect{helper.update_version([0, 0, 1], version_path)}.not.to raise_exception + expect{run_helper('helper.update_version([0, 0, 1], "version.rb")')}.not.to raise_exception end it "can guard clean" do - expect(helper.guard_clean).to be_truthy + expect(run_helper("helper.guard_clean")).to be_truthy end it "can list uncommitted changes" do - File.write(File.expand_path("readme.md", helper.root), "Hello, World!") + File.write(File.expand_path("readme.md", root), "Hello, World!") - expect(helper.uncommitted_changes).to be == ["?? readme.md\n"] + expect(run_helper("helper.uncommitted_changes")).to be == ["?? readme.md\n"] end it "raises an error if repository is dirty" do - File.write(File.expand_path("readme.md", helper.root), "Hello, World!") + File.write(File.expand_path("readme.md", root), "Hello, World!") - expect{helper.guard_clean}.to raise_exception(RuntimeError) + expect{run_helper("helper.guard_clean")}.to raise_exception(Bake::Gem::CommandExecutionError, message: be =~ /uncommited/) end it "can build gem in worktree" do # Create some dummy files: - FileUtils.mkdir_p(File.expand_path("lib", helper.root)) - File.write(File.expand_path("lib/test_gem.rb", helper.root), "# Test gem main file") - File.write(File.expand_path("readme.md", helper.root), "# Test Gem") + FileUtils.mkdir_p(File.expand_path("lib", root)) + File.write(File.expand_path("lib/test_gem.rb", root), "# Test gem main file") + File.write(File.expand_path("readme.md", root), "# Test Gem") # Create a minimal gemspec for testing that uses git to find files gemspec_content = <<~GEMSPEC @@ -136,17 +140,17 @@ def around end GEMSPEC - File.write(File.expand_path("test-gem.gemspec", helper.root), gemspec_content) + File.write(File.expand_path("test-gem.gemspec", root), gemspec_content) # Create an initial commit so we have a HEAD to create worktree from - system("git", "add", ".", chdir: helper.root) - system("git", "commit", "-m", "Initial commit", chdir: helper.root) + system("git", "add", ".", chdir: root) + system("git", "commit", "-m", "Initial commit", chdir: root) - package_path = helper.build_gem_in_worktree(signing_key: false) + package_path = run_helper("helper.build_gem_in_worktree(signing_key: false)") expect(File).to be(:exist?, package_path) # Verify the gem was built in the original location, not worktree - expect(package_path).to be(:start_with?, helper.root) + expect(File).to be(:identical?, package_path, File.join(root, "pkg/test-gem-1.0.0.gem")) package = Gem::Package.new(package_path) expect(package.contents).to be(:include?, "lib/test_gem.rb") expect(package.contents).not.to be(:include?, "lib/bake/gem/helper.rb") diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index f745954..91e0da7 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -5,16 +5,15 @@ require "bake/gem/helper" require "bake/gem/release" -require "bake/context" +require "bake/gem/ruby_context" require "sus/fixtures/console/null_logger" require "sus/fixtures/temporary_directory_context" require "open3" -RELEASE_TASK_ROOT = File.expand_path("../../..", __dir__) - describe Bake::Gem::Release do include Sus::Fixtures::Console::NullLogger include Sus::Fixtures::TemporaryDirectoryContext + include Bake::Gem::RubyContext def git(*arguments) output, status = Open3.capture2e("git", *arguments, chdir: @root) @@ -34,12 +33,10 @@ def commit(message) end def prepare - Dir.chdir(@root) do - registry = Bake::Registry::Aggregate.new - registry.append_path(RELEASE_TASK_ROOT) - registry.append_bakefile(File.join(@root, "bake.rb")) - Bake::Context.new(registry, @root).lookup("gem:release:branch:patch").call - end + ruby(<<~RUBY) + require "bake/gem/release" + Bake::Gem::Release.new(Dir.pwd).bake(Dir.pwd, "gem:release:branch:patch") + RUBY end def around @@ -70,8 +67,6 @@ def after_gem_release_version_increment(version) @base = commit("Initial source") @release = subject.new(@root) yield - ensure - Object.send(:remove_const, :Example) if Object.const_defined?(:Example) end end @@ -87,16 +82,22 @@ def after_gem_release_version_increment(version) end it "builds the committed version even when the caller has loaded the old version" do - prepare - expect(Example::VERSION).to be == "1.0.0" + result = ruby(<<~RUBY) + require "bake/gem/release" + require_relative "lib/example/version" + release = Bake::Gem::Release.new(Dir.pwd) + release.bake(Dir.pwd, "gem:release:branch:patch") + package_path = release.worktree("HEAD") do |path| + release.bake(path, "gem:build", root: File.join(Dir.pwd, "packages with spaces"), signing_key: false) + end + {loaded_version: Example::VERSION, package_path: package_path} + RUBY + expect(result[:loaded_version]).to be == "1.0.0" - @release.worktree("HEAD") do |path| - package_path = @release.bake(path, "gem:build", root: File.join(@root, "packages with spaces"), signing_key: false) - package = Gem::Package.new(package_path) - package.extract_files(File.join(@root, "extracted")) - expect(package.spec.version.to_s).to be == "1.0.1" - expect(File.read(File.join(@root, "extracted/lib/example/version.rb"))).to be(:include?, 'VERSION = "1.0.1"') - end + package = Gem::Package.new(result[:package_path]) + package.extract_files(File.join(@root, "extracted")) + expect(package.spec.version.to_s).to be == "1.0.1" + expect(File.read(File.join(@root, "extracted/lib/example/version.rb"))).to be(:include?, 'VERSION = "1.0.1"') end it "rejects a dirty checkout before changing branch or version" do From 74e85315d418030bdd8e55d9edb6b02ab4b7ff2c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 16:45:57 +1200 Subject: [PATCH 6/8] Use Sus isolated Ruby fixture in repository tests --- fixtures/bake/gem/ruby_context.rb | 33 ------------------------------- gems.rb | 2 +- test/bake/gem/helper.rb | 10 +++++----- test/bake/gem/release.rb | 14 ++++++------- 4 files changed, 13 insertions(+), 46 deletions(-) delete mode 100644 fixtures/bake/gem/ruby_context.rb diff --git a/fixtures/bake/gem/ruby_context.rb b/fixtures/bake/gem/ruby_context.rb deleted file mode 100644 index b52654b..0000000 --- a/fixtures/bake/gem/ruby_context.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "bake/gem/shell" -require "json" -require "open3" -require "rbconfig" - -module Bake - module Gem - # Runs repository code without changing the test process's directory or constants. - module RubyContext - def ruby(source) - script = <<~RUBY - output = $stdout.dup - $stdout.reopen($stderr) - result = begin - #{source} - end - output.write(JSON.generate(result)) - RUBY - - # Load test dependencies from this project while executing in the fixture: - environment = {"RUBYOPT" => nil, "BUNDLE_GEMFILE" => File.expand_path("../../../gems.rb", __dir__)} - output, errors, status = Open3.capture3(environment, RbConfig.ruby, "-rbundler/setup", "-rjson", "-e", script, chdir: root) - raise CommandExecutionError.new(errors, status) unless status.success? - JSON.parse(output, symbolize_names: true) - end - end - end -end diff --git a/gems.rb b/gems.rb index 905fb3f..4049685 100644 --- a/gems.rb +++ b/gems.rb @@ -19,7 +19,7 @@ end group :test do - gem "sus" + gem "sus", "~> 0.38" gem "covered" gem "decode" diff --git a/test/bake/gem/helper.rb b/test/bake/gem/helper.rb index 4b1390b..ffe89be 100644 --- a/test/bake/gem/helper.rb +++ b/test/bake/gem/helper.rb @@ -6,8 +6,8 @@ require "bake/gem/helper" require "sus/fixtures/console/null_logger" +require "sus/fixtures/isolated_ruby_context" require "sus/fixtures/temporary_directory_context" -require "bake/gem/ruby_context" describe Bake::Gem::Helper do let(:helper) {subject.new} @@ -52,10 +52,10 @@ with "repository" do include Sus::Fixtures::TemporaryDirectoryContext - include Bake::Gem::RubyContext + include Sus::Fixtures::IsolatedRubyContext def run_helper(source) - ruby(<<~RUBY) + isolated_ruby(<<~RUBY, chdir: root) require "bake/gem/helper" helper = Bake::Gem::Helper.new #{source} @@ -94,7 +94,7 @@ def around system("git", "commit", "--allow-empty", "-m", "Bump patch version.", chdir: root) # Attempting another version bump should fail - expect{run_helper('helper.update_version([0, 0, 1], "version.rb")')}.to raise_exception(Bake::Gem::CommandExecutionError, message: be =~ /Last commit appears to be a version bump/) + expect{run_helper('helper.update_version([0, 0, 1], "version.rb")')}.to raise_exception(RuntimeError, message: be =~ /Last commit appears to be a version bump/) end it "allows version bump when there are no commits (handles exit code 128)" do @@ -119,7 +119,7 @@ def around it "raises an error if repository is dirty" do File.write(File.expand_path("readme.md", root), "Hello, World!") - expect{run_helper("helper.guard_clean")}.to raise_exception(Bake::Gem::CommandExecutionError, message: be =~ /uncommited/) + expect{run_helper("helper.guard_clean")}.to raise_exception(RuntimeError, message: be =~ /uncommited/) end it "can build gem in worktree" do diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index 91e0da7..5bdfe89 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -5,15 +5,15 @@ require "bake/gem/helper" require "bake/gem/release" -require "bake/gem/ruby_context" require "sus/fixtures/console/null_logger" +require "sus/fixtures/isolated_ruby_context" require "sus/fixtures/temporary_directory_context" require "open3" describe Bake::Gem::Release do include Sus::Fixtures::Console::NullLogger include Sus::Fixtures::TemporaryDirectoryContext - include Bake::Gem::RubyContext + include Sus::Fixtures::IsolatedRubyContext def git(*arguments) output, status = Open3.capture2e("git", *arguments, chdir: @root) @@ -33,9 +33,9 @@ def commit(message) end def prepare - ruby(<<~RUBY) - require "bake/gem/release" - Bake::Gem::Release.new(Dir.pwd).bake(Dir.pwd, "gem:release:branch:patch") + isolated_ruby(<<~RUBY, chdir: root) + require "bake/context" + Bake::Context.load.call("gem:release:branch:patch") RUBY end @@ -82,9 +82,9 @@ def after_gem_release_version_increment(version) end it "builds the committed version even when the caller has loaded the old version" do - result = ruby(<<~RUBY) + result = isolated_ruby(<<~RUBY, chdir: root) require "bake/gem/release" - require_relative "lib/example/version" + require "./lib/example/version" release = Bake::Gem::Release.new(Dir.pwd) release.bake(Dir.pwd, "gem:release:branch:patch") package_path = release.worktree("HEAD") do |path| From 45ed8a3107afad527611f725310d960c39643d4d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 17:16:14 +1200 Subject: [PATCH 7/8] Cover changed release behavior and document it --- releases.md | 7 +++ test/bake/gem/release.rb | 104 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/releases.md b/releases.md index 0d4513a..da1ab7c 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,12 @@ # Releases +## Unreleased + + - Prepare release branches with `gem:release:branch:patch/minor/major`, committing the version bump and all release-hook changes before review. + - Add `gem:release:validate` to detect stale generated release content and unrelated changes. + - Build committed source in a fresh Ruby process and accept an explicit signing key path with `gem:build`. + - Push only the intended release tag when publishing locally. + ## v0.13.1 - Better `version.rb` detection in `version_path` method. diff --git a/test/bake/gem/release.rb b/test/bake/gem/release.rb index 5bdfe89..576ff92 100644 --- a/test/bake/gem/release.rb +++ b/test/bake/gem/release.rb @@ -32,13 +32,17 @@ def commit(message) git("rev-parse", "HEAD") end - def prepare + def bake(*arguments) isolated_ruby(<<~RUBY, chdir: root) require "bake/context" - Bake::Context.load.call("gem:release:branch:patch") + Bake::Context.load.call(*#{arguments.inspect}) RUBY end + def prepare + bake("gem:release:branch:patch") + end + def around super do git("init", "--initial-branch=main") @@ -100,6 +104,87 @@ def after_gem_release_version_increment(version) expect(File.read(File.join(@root, "extracted/lib/example/version.rb"))).to be(:include?, 'VERSION = "1.0.1"') end + it "reports gem metadata through the task" do + expect(bake("gem:metadata")).to be == {name: "example", version: "1.0.0", version_path: "lib/example/version.rb"} + File.delete(File.join(root, "example.gemspec")) + expect{bake("gem:metadata")}.to raise_exception(RuntimeError, message: be =~ /No gemspec found/) + end + + it "commits the version and hook output on the current branch" do + result = bake("gem:release:version:minor") + expect(result[:version].to_s).to be == "1.1.0" + expect(result[:version_path]).to be == "lib/example/version.rb" + expect(git("branch", "--show-current")).to be == "main" + expect(git("status", "--porcelain")).to be == "" + expect(git("show", "HEAD:releases.md")).to be == "1.1.0\nFirst change" + expect(File).not.to be(:exist?, File.join(root, "obsolete.md")) + end + + it "validates a release through the task" do + prepare + result = bake("gem:release:validate", "base=#{@base}") + expect(result).to have_keys(version: be == "1.0.1", base: be == @base, commit: be == git("rev-parse", "HEAD")) + end + + it "rejects prereleases and incomplete version numbers" do + expect{@release.bump("1.0.0", "1.0.1-alpha")}.to raise_exception(RuntimeError, message: be =~ /stable three-part versions/) + expect{@release.bump("1.0", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /stable three-part versions/) + end + + it "removes the temporary worktree when generation fails" do + worktree = nil + expect do + @release.worktree(@base) do |path| + worktree = path + File.write(File.join(path, "partial.txt"), "Partial output") + raise "Generation failed" + end + end.to raise_exception(RuntimeError, message: be == "Generation failed") + expect(File).not.to be(:exist?, worktree) + expect(git("worktree", "list", "--porcelain")).not.to be(:include?, worktree) + end + + it "accepts a signing key path and produces a verifiable signed gem" do + key = OpenSSL::PKey::RSA.new(2048) + certificate = Gem::Security.create_cert_email("test@example.com", key) + write("release.cert", certificate.to_pem) + write("release.pem", key.to_pem) + gemspec_path = File.join(root, "example.gemspec") + File.write(gemspec_path, File.read(gemspec_path).sub('spec.summary = "Example"', 'spec.summary = "Example"; spec.cert_chain = ["release.cert"]')) + path = bake("gem:build", "signing_key=#{File.join(root, 'release.pem')}") + package = Gem::Package.new(path, Gem::Security::Policy.new("Release Test", only_trusted: false)) + expect(package.verify).to be_truthy + expect(OpenSSL::X509::Certificate.new(package.spec.cert_chain.last).to_der).to be == certificate.to_der + end + + it "requires a signing key when requested and permits unsigned builds" do + expect{bake("gem:build", "signing_key=true")}.to raise_exception(ArgumentError, message: be =~ /Signing key is required/) + path = bake("gem:build", "signing_key=false") + expect(Gem::Package.new(path).spec.cert_chain).to be == [] + end + + it "publishes the local release commit and only its intended tag" do + write(".gitignore", "remote.git/\npkg/\n") + commit("Configure local remote") + git("init", "--bare", "remote.git") + git("remote", "add", "origin", File.join(root, "remote.git")) + git("push", "--set-upstream", "origin", "main") + git("tag", "unrelated") + result = isolated_ruby(<<~RUBY, chdir: root) + require "bake/context" + context = Bake::Context.load + helper = context.lookup("gem:release").instance.helper + published = nil + helper.define_singleton_method(:push_gem) {|path:| published = Gem::Package.new(path).spec.version.to_s} + result = context.call("gem:release:patch") + result.merge(published: published) + RUBY + expect(result).to have_keys(tag: be == "v1.0.1", published: be == "1.0.1") + expect(git("--git-dir=remote.git", "tag")).to be == "v1.0.1" + expect(git("--git-dir=remote.git", "rev-parse", "main")).to be == git("rev-parse", "HEAD") + expect(git("--git-dir=remote.git", "rev-parse", "v1.0.1")).to be == git("rev-parse", "HEAD") + end + it "rejects a dirty checkout before changing branch or version" do write("unrelated.txt", "Uncommitted") expect{prepare}.to raise_exception(RuntimeError, message: be =~ /uncommited/) @@ -113,6 +198,13 @@ def after_gem_release_version_increment(version) expect(git("status", "--porcelain")).to be == "" end + it "rejects an existing release tag before modifying files" do + git("tag", "v1.0.1") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /tag v1.0.1 already exists/) + expect(git("branch", "--show-current")).to be == "main" + expect(git("status", "--porcelain")).to be == "" + end + it "rejects detached preparation before modifying files" do git("checkout", "--detach") expect{prepare}.to raise_exception(RuntimeError, message: be =~ /branch checkout/) @@ -142,6 +234,14 @@ def after_gem_release_version_increment(version) expect(git("diff", "--cached", "--name-only")).to be == "" end + it "rejects package output generated by hooks before staging it" do + write("bake.rb", "def after_gem_release_version_increment(version); File.write(\"example.gem\", \"Package\"); end\n") + base = commit("Packaging hook") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /build output/) + expect(git("rev-parse", "HEAD")).to be == base + expect(git("diff", "--cached", "--name-only")).to be == "" + end + it "accepts an updated base when regenerated content is unchanged" do prepare git("checkout", "main") From 51bdc209728edb110bd91b4fdba6d7e11c0dd267 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 17:22:41 +1200 Subject: [PATCH 8/8] Clarify signing key argument parsing --- bake/gem.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bake/gem.rb b/bake/gem.rb index 093ccff..6cd3bc8 100644 --- a/bake/gem.rb +++ b/bake/gem.rb @@ -31,7 +31,7 @@ def metadata # Build the gem into the pkg directory. # @parameter root [String] The root directory to build the gem into. Defaults to `pkg`. -# @parameter signing_key [String | Boolean | Nil] A signing key path, true to require signing, or false to disable signing. +# @parameter signing_key [String | Nil] A signing key path, "true" to require signing, or "false" to disable signing. def build(root: "pkg", signing_key: nil) # Accept boolean command line options while preserving signing key paths: signing_key = true if signing_key == "true"