Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 25 additions & 8 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/lifecycle_hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 6 additions & 10 deletions examples/lifecycle_hooks/spec/release_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/ruby-gem-release/rspec/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions spec/ruby_gem_release/rspec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading