diff --git a/CHANGELOG.md b/CHANGELOG.md index 7510cb0..6f2afe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). @@ -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) diff --git a/docs/testing.md b/docs/testing.md index d412980..9c28068 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/examples/lifecycle_hooks/spec/release_spec.rb b/examples/lifecycle_hooks/spec/release_spec.rb index 15e8b50..b813942 100644 --- a/examples/lifecycle_hooks/spec/release_spec.rb +++ b/examples/lifecycle_hooks/spec/release_spec.rb @@ -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' diff --git a/lib/ruby-gem-release/rspec/project.rb b/lib/ruby-gem-release/rspec/project.rb index dabac4f..ea18ebb 100644 --- a/lib/ruby-gem-release/rspec/project.rb +++ b/lib/ruby-gem-release/rspec/project.rb @@ -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) @@ -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 diff --git a/lib/ruby-gem-release/version.rb b/lib/ruby-gem-release/version.rb index 8e1495b..126c90b 100644 --- a/lib/ruby-gem-release/version.rb +++ b/lib/ruby-gem-release/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RubyGemRelease - VERSION = '0.5.2' + VERSION = '0.6.0' end diff --git a/spec/ruby_gem_release/rspec_spec.rb b/spec/ruby_gem_release/rspec_spec.rb index 0a4046e..9e1ac65 100644 --- a/spec/ruby_gem_release/rspec_spec.rb +++ b/spec/ruby_gem_release/rspec_spec.rb @@ -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") @@ -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') @@ -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) @@ -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 @@ -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.') @@ -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/) @@ -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