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 @@ -6,6 +6,7 @@
- [#51](https://github.com/dblock/ruby-gem-release/pull/51): Carry changelog subsections into the next release and remove multiple contribution placeholders - [@dblock](https://github.com/dblock).
- [#52](https://github.com/dblock/ruby-gem-release/pull/52): Support configured contribution placeholders and complete category templates in Keep a Changelog releases - [@dblock](https://github.com/dblock).
- [#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).
- Your contribution here.

## 0.5.1 (2026/11/10)
Expand Down
10 changes: 10 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ See the [complete spec](../examples/lifecycle_hooks/spec/release_spec.rb) for al

`with_release_project` creates a temporary checkout, local bare `origin`, and private artifact directory, then cleans them up even if the spec fails. `project.write(path, content)` creates files; `commit!` commits and pushes fixture edits to that local remote. No files or commits are changed in your working checkout.

Copy real project files instead of duplicating their contents in the spec:

```ruby
project.copy_files(path: File.expand_path('..', __dir__))
```

`path:` is the source directory. By default, `copy_files` copies all files recursively, including hidden files, but skips `.git` metadata to preserve the fixture's isolated repository. Pass `files: %w[README.md tasks/release_notes.rb]` to copy only selected relative files, or `files: []` to copy nothing.

Copying preserves relative paths and file modes. Missing files, directory entries, absolute paths, Git metadata, and paths or symbolic links that escape either root are rejected. All selected files are checked before copying.

`release!` runs capture, validation, preparation, resolution, checkpoint, publication, development bump, and success in separate Rake processes. Pass `test_task: 'verify_release'` to run a fixture-specific check after resolution; the default skips the project's test suite to avoid recursively running this spec. Use `run_task('release:prepare')` for individual tasks and `git(...)` or `remote_file(ref, path)` to inspect results.

The child processes retain Ruby/Bundler paths but do not inherit Actions variables, credentials, Git overrides, or outer release state. `project.environment` lets you add explicit fixture inputs. The helper uses the currently loaded release gem and parent bundle.
Expand Down
5 changes: 1 addition & 4 deletions examples/lifecycle_hooks/spec/release_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@

it 'updates README in the release and next development commits' do
with_release_project do |project|
%w[release_notes.rb release_workflow.rb].each do |path|
project.write(path, File.read(File.expand_path("../#{path}", __dir__)))
end
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'
Expand All @@ -32,7 +30,6 @@
end
RUBY
project.write('lib/example/version.rb', "VERSION = '0.1.0'\n")
project.write('README.md', File.read(File.expand_path('../README.md', __dir__)))
project.write('CHANGELOG.md', "# Changelog\n\n## [Unreleased]\n\n- A change.\n")
project.commit!

Expand Down
47 changes: 47 additions & 0 deletions lib/ruby-gem-release/rspec/project.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# frozen_string_literal: true

require 'fileutils'
require 'find'
require 'json'
require 'open3'
require 'pathname'

module RubyGemRelease
module RSpec
Expand All @@ -26,6 +28,20 @@ def write(path, content)
File.write(destination, content)
end

def copy_files(path:, files: nil)
source_root, destination_root = [path, @checkout].map { |directory| File.realpath(directory) }
copies = (files || source_files(source_root)).map do |file|
source = copy_path(source_root, file)
raise ArgumentError, "fixture source must be a regular file: #{file}" unless File.file?(source)

[source, copy_path(destination_root, file)]
end
copies.each do |source, destination|
FileUtils.mkdir_p(File.dirname(destination))
FileUtils.copy_file(source, destination, true)
end
end

def commit!(message = 'Prepare release fixture')
git('add', '.')
git('commit', '--quiet', '-m', message)
Expand Down Expand Up @@ -61,6 +77,37 @@ def release!(version: '', test_task: nil)

private

def source_files(root)
Find.find(root).filter_map do |source|
next if source == root

Find.prune if File.basename(source) == '.git'

source.delete_prefix("#{root}/") unless File.directory?(source) && !File.symlink?(source)
end
end

def copy_path(root, path)
path = File.path(path)
candidate = File.expand_path(path, root)
raise ArgumentError, 'fixture paths must be relative files inside their root' if Pathname.new(path).absolute? || !candidate.start_with?("#{root}/")

ancestor = candidate
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
resolved = File.realpath(ancestor)
raise ArgumentError, 'fixture paths must stay inside their root' unless resolved == root || resolved.start_with?("#{root}/")

check_git_metadata(root, candidate, resolved)

candidate
end

def check_git_metadata(root, *paths)
return unless paths.any? { |path| path.delete_prefix("#{root}/").split('/').include?('.git') }

raise ArgumentError, 'fixture files must not replace Git metadata'
end

def initialize_git
git('init', '--bare', '--quiet', '-b', 'master', @remote)
git('init', '--quiet', '-b', 'master')
Expand Down
108 changes: 108 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,114 @@
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')
FileUtils.mkdir_p(File.join(root, 'tasks'))
File.binwrite(File.join(root, 'README.md'), "Project\n")
File.binwrite(File.join(root, 'tasks/release.rb'), "\x00\xFF")
File.chmod(0o751, File.join(root, 'tasks/release.rb'))
File.write(File.join(root, 'ignored'), 'Do not copy.')

2.times { project.copy_files(path: root, files: %w[README.md tasks/release.rb]) }

expect(File.binread(File.join(project.checkout, 'README.md'))).to eq("Project\n")
expect(File.binread(File.join(project.checkout, 'tasks/release.rb'))).to eq("\x00\xFF".b)
expect(File.stat(File.join(project.checkout, 'tasks/release.rb')).mode & 0o777).to eq(0o751)
expect(File).not_to exist(File.join(project.checkout, 'ignored'))
end
end

it 'copies all files recursively by default, including hidden files but not Git metadata' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(%w[tasks .config .git nested].map { |path| File.join(root, path) })
File.write(File.join(root, 'README.md'), 'Project')
File.binwrite(File.join(root, 'tasks/release.rb'), "\x00\xFF")
File.chmod(0o751, File.join(root, 'tasks/release.rb'))
File.write(File.join(root, '.hidden'), 'Hidden file')
File.write(File.join(root, '.config/settings'), 'Hidden directory')
File.write(File.join(root, '.git/config'), 'Do not copy repository metadata.')
File.write(File.join(root, 'nested/.git'), 'Do not copy worktree metadata.')
original_config = File.read(File.join(project.checkout, '.git/config'))

project.copy_files(path: root, files: [])
expect(Dir.children(project.checkout)).to eq(['.git'])
2.times { project.copy_files(path: root) }

expect(File.read(File.join(project.checkout, 'README.md'))).to eq('Project')
expect(File.binread(File.join(project.checkout, 'tasks/release.rb'))).to eq("\x00\xFF".b)
expect(File.stat(File.join(project.checkout, 'tasks/release.rb')).mode & 0o777).to eq(0o751)
expect(File.read(File.join(project.checkout, '.hidden'))).to eq('Hidden file')
expect(File.read(File.join(project.checkout, '.config/settings'))).to eq('Hidden directory')
expect(File.read(File.join(project.checkout, '.git/config'))).to eq(original_config)
expect(File).not_to exist(File.join(project.checkout, 'nested/.git'))
expect(project.git('remote', 'get-url', 'origin')).to eq(project.remote)
end
end

it 'rejects missing files and directories before copying any files' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(File.join(root, 'tasks'))
File.write(File.join(root, 'README.md'), 'Project')

%w[missing tasks].each do |invalid|
expect { project.copy_files(path: root, files: ['README.md', invalid]) }.to raise_error(ArgumentError, /regular file/)
expect(File).not_to exist(File.join(project.checkout, 'README.md'))
end
end
end

it 'rejects absolute paths and paths outside the source root' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(root)
File.write(File.join(project.directory, 'outside'), 'Keep.')

['../outside', File.join(project.directory, 'outside')].each do |invalid|
expect { project.copy_files(path: root, files: [invalid]) }.to raise_error(ArgumentError, /relative files inside their root/)
end
expect(File.read(File.join(project.directory, 'outside'))).to eq('Keep.')
end
end

it 'rejects source and destination symbolic links outside their roots' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(File.join(root, 'tasks'))
outside = File.join(project.directory, 'outside')
FileUtils.mkdir_p(outside)
File.write(File.join(outside, 'release.rb'), 'Keep.')
File.symlink(File.join(outside, 'release.rb'), File.join(root, 'linked.rb'))
expect { project.copy_files(path: root, files: ['linked.rb']) }.to raise_error(ArgumentError, /stay inside/)
expect { project.copy_files(path: root) }.to raise_error(ArgumentError, /stay inside/)

File.write(File.join(root, 'tasks/release.rb'), 'Replace.')
File.symlink(outside, File.join(project.checkout, 'tasks'))
expect { project.copy_files(path: root, files: ['tasks/release.rb']) }.to raise_error(ArgumentError, /stay inside/)
File.symlink(File.join(outside, 'release.rb'), File.join(project.checkout, 'release.rb'))
File.write(File.join(root, 'release.rb'), 'Replace.')
expect { project.copy_files(path: root, files: ['release.rb']) }.to raise_error(ArgumentError, /stay inside/)
expect(File.read(File.join(outside, 'release.rb'))).to eq('Keep.')
end
end

it 'rejects explicit Git metadata and destination aliases to it' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(File.join(root, '.git'))
File.write(File.join(root, '.git/config'), 'Do not copy.')
File.write(File.join(root, 'config'), 'Do not replace.')
original_config = File.read(File.join(project.checkout, '.git/config'))

expect { project.copy_files(path: root, files: ['.git/config']) }.to raise_error(ArgumentError, /Git metadata/)
File.symlink(File.join(project.checkout, '.git/config'), File.join(project.checkout, 'config'))
expect { project.copy_files(path: root, files: ['config']) }.to raise_error(ArgumentError, /Git metadata/)
expect(File.read(File.join(project.checkout, '.git/config'))).to eq(original_config)
end
end

[RubyGemRelease::RSpec::Destination, RubyGemRelease::RSpec::Hosting].each do |adapter|
context "with #{adapter}" do
let(:release) { RubyGemRelease::Release.new(version: '0.1.0', gemspec_version: '0.1.0', date: Date.new(2026, 11, 11)) }
Expand Down
Loading