diff --git a/CHANGELOG.md b/CHANGELOG.md index dfef821..7510cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [#53](https://github.com/dblock/ruby-gem-release/pull/53): Add Markdown section replacement and text substitution for project-specific release documentation - [@dblock](https://github.com/dblock). - [#54](https://github.com/dblock/ruby-gem-release/pull/54): Add whole-project or selected fixture file copying to the optional release spec helper - [@dblock](https://github.com/dblock). - [#55](https://github.com/dblock/ruby-gem-release/pull/55): Run full and integration specs concurrently while retaining isolated release processes and combined coverage - [@dblock](https://github.com/dblock). +- [#56](https://github.com/dblock/ruby-gem-release/pull/56): Configure isolated release fixtures from project workflow builders with local test adapters - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.5.1 (2026/11/10) diff --git a/docs/testing.md b/docs/testing.md index bd1a50e..d412980 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,8 @@ RSpec.describe 'My release configuration' do it 'updates release documentation' do with_release_project do |project| - # Write the gemspec, version, changelog, README, and test Rakefile here. + # Copy the actual gemspec, version, changelog, README, and workflow files here. + project.configure_workflow(require: 'release_workflow', builder: 'ProjectReleaseWorkflow.build') project.commit! project.release!(version: '0.2.0') @@ -45,16 +46,32 @@ The child processes retain Ruby/Bundler paths but do not inherit Actions variabl ## Local Publication -Keep the project's real version object, changelog, and hooks. In the fixture Rakefile, pass these test-only overrides to its workflow builder: +Keep the project's real version object, changelog, and hooks. For a project exposing `GrapeRelease.workflow` in `tasks/release.rb`, configure the fixture after copying that file and its dependencies: ```ruby -require 'ruby-gem-release/rspec' +project.configure_workflow( + require: 'tasks/release', + builder: 'GrapeRelease.workflow' +) +``` + +`configure_workflow` writes only the fixture's Rakefile. It requires `ruby-gem-release/rspec`, loads the copied Ruby file, calls the builder with the existing local `Provider`, `Destination`, and `Hosting` adapters, and invokes `install_tasks`. Production configuration is unchanged; `release!` and `run_task` still execute the same release tasks in separate processes. -ProjectReleaseWorkflow.build( - provider: RubyGemRelease::RSpec::Provider.new, - destination: RubyGemRelease::RSpec::Destination.new, - hosting: RubyGemRelease::RSpec::Hosting.new -).install_tasks +`require:` is a path inside the checkout, with or without `.rb`. Absolute paths, Git metadata, and paths or symbolic links escaping the checkout are rejected. The path is encoded as a Ruby string literal. `builder:` must be a constant path and method, such as `MyProject::Release.workflow`, not an arbitrary Ruby expression. Invalid arguments are rejected before replacing the Rakefile. + +For custom setup or adapter options, keep using `project.write('Rakefile', ...)`. For example: + +```ruby +project.write('Rakefile', <<~RUBY) + require 'ruby-gem-release/rspec' + require_relative 'release_workflow' + + ProjectReleaseWorkflow.build( + provider: RubyGemRelease::RSpec::Provider.new, + destination: RubyGemRelease::RSpec::Destination.new(failure: 'Publication rejected'), + hosting: RubyGemRelease::RSpec::Hosting.new + ).install_tasks +RUBY ``` The provider transports exported values and failed-task information between fixture processes. The destination and hosting adapters copy the exact built archive to `registry/VERSION.gem` and `hosting/VERSION.gem` under `RELEASE_DATA_DIR`, without authentication or network publication. Repetition accepts identical archives and rejects checksum conflicts. Set `failure: 'Publication rejected'` on either adapter to exercise a failure. diff --git a/examples/lifecycle_hooks/README.md b/examples/lifecycle_hooks/README.md index 61d7ead..3a35798 100644 --- a/examples/lifecycle_hooks/README.md +++ b/examples/lifecycle_hooks/README.md @@ -8,6 +8,6 @@ Development documentation. ## Testing -The [complete spec](spec/release_spec.rb) copies the actual workflow and this README into a disposable project. It replaces registry and hosting publication with local adapters, runs the release, and checks both Git commits. Deleting a README hook or removing the stable-release paragraph causes the spec to fail. +The [complete spec](spec/release_spec.rb) copies the actual workflow and this README into a disposable project. `project.configure_workflow` installs that builder with local adapters without changing the production Rakefile. The spec runs the release, checks both Git commits, and verifies that the registry and hosting adapters stored the exact archive locally. Deleting a README hook or removing the stable-release paragraph causes the spec to fail. From the `ruby-gem-release` checkout, run `bundle exec rspec examples/lifecycle_hooks/spec/release_spec.rb`. See [Testing Custom Workflows](../../docs/testing.md) to adapt the helper to your own project. diff --git a/examples/lifecycle_hooks/spec/release_spec.rb b/examples/lifecycle_hooks/spec/release_spec.rb index db9b2f9..15e8b50 100644 --- a/examples/lifecycle_hooks/spec/release_spec.rb +++ b/examples/lifecycle_hooks/spec/release_spec.rb @@ -9,16 +9,7 @@ it 'updates README in the release and next development commits' do with_release_project do |project| project.copy_files(path: File.expand_path('..', __dir__), files: %w[release_notes.rb release_workflow.rb README.md]) - project.write('Rakefile', <<~RUBY) - require 'ruby-gem-release/rspec' - require_relative 'release_workflow' - - ProjectReleaseWorkflow.build( - provider: RubyGemRelease::RSpec::Provider.new, - destination: RubyGemRelease::RSpec::Destination.new, - hosting: RubyGemRelease::RSpec::Hosting.new - ).install_tasks - RUBY + project.configure_workflow(require: 'release_workflow', builder: 'ProjectReleaseWorkflow.build') project.write('example.gemspec', <<~RUBY) require_relative 'lib/example/version' Gem::Specification.new do |spec| @@ -37,6 +28,11 @@ expect(project.remote_file('v0.2.0', 'README.md')).to include('stable release of the project, 0.2.0') expect(project.remote_file('master', 'README.md')).to include('next release of the project, which should be 0.2.1') + artifacts = project.environment.fetch('RELEASE_DATA_DIR') + archive = File.binread(File.join(artifacts, 'release.gem')) + %w[registry hosting].each do |adapter| + expect(File.binread(File.join(artifacts, adapter, '0.2.0.gem'))).to eq(archive) + end expect(project.git('status', '--porcelain')).to eq('') end end diff --git a/lib/ruby-gem-release/rspec/project.rb b/lib/ruby-gem-release/rspec/project.rb index 971823c..dabac4f 100644 --- a/lib/ruby-gem-release/rspec/project.rb +++ b/lib/ruby-gem-release/rspec/project.rb @@ -42,6 +42,22 @@ def copy_files(path:, files: nil) end end + def configure_workflow(require:, builder:) + raise ArgumentError, 'workflow builder must be a constant path and method' unless builder.is_a?(String) && /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\.[a-z_]\w*[!?]?\z/.match?(builder) + + source = workflow_source(require) + write('Rakefile', <<~RUBY) + require 'ruby-gem-release/rspec' + require_relative #{source.dump} + + #{builder}( + provider: RubyGemRelease::RSpec::Provider.new, + destination: RubyGemRelease::RSpec::Destination.new, + hosting: RubyGemRelease::RSpec::Hosting.new + ).install_tasks + RUBY + end + def commit!(message = 'Prepare release fixture') git('add', '.') git('commit', '--quiet', '-m', message) @@ -77,6 +93,16 @@ def release!(version: '', test_task: nil) private + def workflow_source(path) + raise ArgumentError, 'workflow source must be a nonempty string' unless path.is_a?(String) && !path.empty? + + source = "#{path.delete_suffix('.rb')}.rb" + root = File.realpath(@checkout) + copy_path(root, source) + copy_path(root, 'Rakefile') + source + end + def source_files(root) Find.find(root).filter_map do |source| next if source == root diff --git a/spec/ruby_gem_release/rspec_spec.rb b/spec/ruby_gem_release/rspec_spec.rb index c5c5b8f..0a4046e 100644 --- a/spec/ruby_gem_release/rspec_spec.rb +++ b/spec/ruby_gem_release/rspec_spec.rb @@ -47,6 +47,66 @@ end end + it 'loads a literal workflow path and installs the project builder with local adapters' do + with_release_project do |project| + source = "release-\"\#{raise 'Unexpected interpolation'}" + project.write("#{source}.rb", <<~RUBY) + module FixtureBuilders + module Release + def self.workflow(provider:, destination:, hosting:) + raise 'Destination is not local' unless destination.is_a?(RubyGemRelease::RSpec::Destination) + raise 'Hosting is not local' unless hosting.is_a?(RubyGemRelease::RSpec::Hosting) + + @provider = provider + self + end + + def self.install_tasks + Rake::Task.define_task(:configured_hook) do + @provider.export('PROJECT_HOOK' => 'ran') + end + end + end + end + RUBY + + project.configure_workflow(require: "#{source}.rb", builder: '::FixtureBuilders::Release.workflow') + project.run_task('configured_hook') + + expect(project.environment.fetch('PROJECT_HOOK')).to eq('ran') + end + end + + it 'rejects builder expressions before replacing a custom Rakefile' do + with_release_project do |project| + project.write('Rakefile', 'Custom setup') + [nil, :workflow, '', 'Release', 'release.workflow', 'Release.workflow()', "Release.workflow\nraise 'Injected'"].each do |builder| + expect { project.configure_workflow(require: 'release', builder: builder) }.to raise_error(ArgumentError, /builder/) + end + expect(File.read(File.join(project.checkout, 'Rakefile'))).to eq('Custom setup') + end + end + + it 'rejects invalid workflow paths and links that escape the checkout without changing files' do + with_release_project do |project| + project.write('Rakefile', 'Custom setup') + outside = File.join(project.directory, 'outside.rb') + File.write(outside, 'Do not load.') + File.symlink(outside, File.join(project.checkout, 'release.rb')) + + [nil, :release, '', '../outside', outside, '.git/config', 'release', 'release.rb', "release\0"].each do |source| + expect { project.configure_workflow(require: source, builder: 'Release.workflow') }.to raise_error(ArgumentError) + end + expect(File.read(File.join(project.checkout, 'Rakefile'))).to eq('Custom setup') + + File.unlink(File.join(project.checkout, 'Rakefile')) + File.symlink(outside, File.join(project.checkout, 'Rakefile')) + project.write('workflow.rb', 'Project builder source') + expect { project.configure_workflow(require: 'workflow', builder: 'Release.workflow') }.to raise_error(ArgumentError, /stay inside/) + expect(File.read(outside)).to eq('Do not load.') + end + end + it 'copies explicit fixture files with relative paths, binary content, and file modes' do with_release_project do |project| root = File.join(project.directory, 'source')