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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## 0.5.2 (Next)
## 0.6.0 (Next)

- [#50](https://github.com/dblock/ruby-gem-release/pull/50): Add optional release spec helpers, local publication adapters, and a complete example for testing project-specific release customizations in isolated repositories - [@dblock](https://github.com/dblock).
- [#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).
Expand All @@ -9,6 +9,7 @@
- [#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).
- [#57](https://github.com/dblock/ruby-gem-release/pull/57): Support relative globs and mixed file selectors when copying release spec fixtures - [@dblock](https://github.com/dblock).
- Your contribution here.

## 0.5.1 (2026/11/10)
Expand Down
25 changes: 19 additions & 6 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,32 @@ 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:
`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.

## Copy Fixture Files

Copy real project files instead of duplicating their contents in the spec. `path:` is the source directory; `files:` accepts a single relative path or glob, or an array mixing both:

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

project.copy_files(path: source_root)
project.copy_files(path: source_root, files: 'README.md')
project.copy_files(path: source_root, files: %w[README.md bin/run])
project.copy_files(path: source_root, files: [])
project.copy_files(path: source_root, files: '**/*.{js,md}')
project.copy_files(path: source_root, files: ['README.md', 'lib/**/*.rb', 'bin/*'])
```

`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.
Omitting `files:` or passing `nil` copies all files recursively. An empty array copies nothing. Existing exact paths take precedence over glob syntax, so a file named `[notes].md` is copied literally. Overlapping selectors copy each relative file only once.

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.
Globs are matched relative to `path:`, not the current directory. Supported patterns include `*`, `?`, character classes such as `[ab]` or `[a-z]`, brace alternatives such as `*.{rb,md}`, and recursive `**/`. A leading `./` is accepted. `*` does not cross directory separators; use `**/*` for all nested files. Hidden files and hidden directories participate in matching, including with `**/*`. Git ignore rules are not applied, so select only the files needed by your fixture to avoid copying dependency directories and build outputs.

`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.
Whole-project copying and glob expansion skip `.git` files and directories at every level to preserve the fixture's isolated repository. Explicit paths or patterns containing a `.git` component are rejected. Glob expansion selects files rather than ordinary directories and never traverses symbolic-link directories. A matched symbolic link must resolve to a regular file inside the source root; its contents are copied, not the link itself.

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.
Copying preserves relative paths, binary content, and file modes, and replaces selected destination files. Missing literal files, patterns matching no files, explicit directory entries, absolute paths, Git metadata, and paths or symbolic links that escape either root are rejected. Every selector must match at least one file. All selected sources and destinations are checked before any copying, so a bad selector or escaping destination link cannot leave a partially copied fixture. Empty directories are not copied.

## Local Publication

Expand Down
2 changes: 1 addition & 1 deletion examples/lifecycle_hooks/spec/release_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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.copy_files(path: File.expand_path('..', __dir__), files: %w[*.rb README.md])
project.configure_workflow(require: 'release_workflow', builder: 'ProjectReleaseWorkflow.build')
project.write('example.gemspec', <<~RUBY)
require_relative 'lib/example/version'
Expand Down
23 changes: 22 additions & 1 deletion lib/ruby-gem-release/rspec/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def write(path, content)

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|
copies = selected_files(source_root, files).map do |file|
source = copy_path(source_root, file)
raise ArgumentError, "fixture source must be a regular file: #{file}" unless File.file?(source)

Expand Down Expand Up @@ -103,6 +103,27 @@ def workflow_source(path)
source
end

def selected_files(root, files)
return source_files(root) if files.nil?

available = nil
Array(files).flat_map do |file|
source = copy_path(root, file)
pattern = source.delete_prefix("#{root}/")
next pattern if File.exist?(source) || !pattern.match?(/[*?{\[]/)

available ||= source_files(root)
glob_files(pattern, available)
end.uniq
end

def glob_files(pattern, available)
matches = available.select { |file| File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
raise ArgumentError, "fixture source must match at least one regular file: #{pattern}" if matches.empty?

matches
end

def source_files(root)
Find.find(root).filter_map do |source|
next if source == root
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby-gem-release/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module RubyGemRelease
VERSION = '0.5.2'
VERSION = '0.6.0'
end
79 changes: 74 additions & 5 deletions spec/ruby_gem_release/rspec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def self.install_tasks
File.chmod(0o751, File.join(root, 'tasks/release.rb'))
File.write(File.join(root, 'ignored'), 'Do not copy.')

project.copy_files(path: root, files: 'README.md')
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")
Expand All @@ -125,6 +126,66 @@ def self.install_tasks
end
end

it 'copies recursive glob matches with brace alternatives, hidden files, and preserved modes' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(%w[lib/nested .config .git nested directory.md].map { |path| File.join(root, path) })
%w[README.md lib/nested/notes.md .hidden.md .config/settings.md].each { |path| File.write(File.join(root, path), path) }
File.binwrite(File.join(root, 'lib/main.js'), "\x00\xFF")
File.chmod(0o751, File.join(root, 'lib/main.js'))
File.write(File.join(root, 'ignored.rb'), 'Do not copy.')
File.write(File.join(root, '.git/private.md'), 'Do not copy Git 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: './**/*.{js,md}')

%w[README.md lib/nested/notes.md .hidden.md .config/settings.md].each do |path|
expect(File.read(File.join(project.checkout, path))).to eq(path)
end
expect(File.binread(File.join(project.checkout, 'lib/main.js'))).to eq("\x00\xFF".b)
expect(File.stat(File.join(project.checkout, 'lib/main.js')).mode & 0o777).to eq(0o751)
expect(File).not_to exist(File.join(project.checkout, 'ignored.rb'))
expect(File).not_to exist(File.join(project.checkout, 'directory.md'))
expect(File).not_to exist(File.join(project.checkout, '.git/private.md'))
expect(File).not_to exist(File.join(project.checkout, 'nested/.git'))
expect(File.read(File.join(project.checkout, '.git/config'))).to eq(original_config)
end
end

it 'combines and deduplicates literal paths and globs while preferring existing exact paths' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(%w[lib bin].map { |path| File.join(root, path) })
%w[bin/run lib/a.rb lib/b.rb lib/aa.rb [notes].md n.md].each { |path| File.write(File.join(root, path), path) }
allow(FileUtils).to receive(:copy_file).and_call_original

project.copy_files(path: root, files: ['bin/run', 'lib/[ab].rb', 'lib/?.rb', './lib/a.rb', '[notes].md'])

expect(FileUtils).to have_received(:copy_file).with(File.realpath(File.join(root, 'lib/a.rb')), File.join(File.realpath(project.checkout), 'lib/a.rb'), true).once
%w[bin/run lib/a.rb lib/b.rb [notes].md].each do |path|
expect(File.read(File.join(project.checkout, path))).to eq(path)
end
expect(File).not_to exist(File.join(project.checkout, 'lib/aa.rb'))
expect(File).not_to exist(File.join(project.checkout, 'n.md'))
end
end

it 'does not traverse symbolic-link directories when expanding recursive globs' do
with_release_project do |project|
root = File.join(project.directory, 'source')
FileUtils.mkdir_p(File.join(root, 'lib'))
File.write(File.join(root, 'lib/release.rb'), 'Project workflow')
File.symlink(File.join(root, 'lib'), File.join(root, 'alias'))

expect { project.copy_files(path: root, files: 'alias/*.rb') }.to raise_error(ArgumentError, /regular file/)
project.copy_files(path: root, files: '**/*.rb')

expect(File.read(File.join(project.checkout, 'lib/release.rb'))).to eq('Project workflow')
expect(File).not_to exist(File.join(project.checkout, 'alias'))
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')
Expand All @@ -140,7 +201,8 @@ def self.install_tasks

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

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)
Expand All @@ -156,10 +218,10 @@ def self.install_tasks
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'))
FileUtils.mkdir_p(File.join(root, 'tasks/empty.rb'))
File.write(File.join(root, 'README.md'), 'Project')

%w[missing tasks].each do |invalid|
%w[missing tasks missing/**/*.rb *.txt **/*.rb].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
Expand All @@ -172,7 +234,7 @@ def self.install_tasks
FileUtils.mkdir_p(root)
File.write(File.join(project.directory, 'outside'), 'Keep.')

['../outside', File.join(project.directory, 'outside')].each do |invalid|
['../outside', '../*', File.join(project.directory, 'outside'), File.join(project.directory, '*')].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.')
Expand All @@ -189,10 +251,14 @@ def self.install_tasks
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/)
expect { project.copy_files(path: root, files: '*.rb') }.to raise_error(ArgumentError, /stay inside/)
File.symlink(outside, File.join(root, 'linked'))
expect { project.copy_files(path: root, files: 'linked/*.rb') }.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/)
expect { project.copy_files(path: root, files: 'tasks/*.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/)
Expand All @@ -208,9 +274,12 @@ def self.install_tasks
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/)
%w[.git/config .git/* **/.git/*].each do |invalid|
expect { project.copy_files(path: root, files: [invalid]) }.to raise_error(ArgumentError, /Git metadata/)
end
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 { project.copy_files(path: root, files: 'con*') }.to raise_error(ArgumentError, /Git metadata/)
expect(File.read(File.join(project.checkout, '.git/config'))).to eq(original_config)
end
end
Expand Down
Loading