From a1af4393f9dd958ea9f776533bb7ec082dffc3e0 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 11 Nov 2026 09:41:16 -0500 Subject: [PATCH] Add project file copying to release spec fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/testing.md | 10 ++ examples/lifecycle_hooks/spec/release_spec.rb | 5 +- lib/ruby-gem-release/rspec/project.rb | 47 ++++++++ spec/ruby_gem_release/rspec_spec.rb | 108 ++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8883c..52e438c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/testing.md b/docs/testing.md index d647756..bd1a50e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/examples/lifecycle_hooks/spec/release_spec.rb b/examples/lifecycle_hooks/spec/release_spec.rb index b9c512c..db9b2f9 100644 --- a/examples/lifecycle_hooks/spec/release_spec.rb +++ b/examples/lifecycle_hooks/spec/release_spec.rb @@ -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' @@ -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! diff --git a/lib/ruby-gem-release/rspec/project.rb b/lib/ruby-gem-release/rspec/project.rb index 765d6b3..971823c 100644 --- a/lib/ruby-gem-release/rspec/project.rb +++ b/lib/ruby-gem-release/rspec/project.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true require 'fileutils' +require 'find' require 'json' require 'open3' +require 'pathname' module RubyGemRelease module RSpec @@ -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) @@ -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') diff --git a/spec/ruby_gem_release/rspec_spec.rb b/spec/ruby_gem_release/rspec_spec.rb index f386ac0..c5c5b8f 100644 --- a/spec/ruby_gem_release/rspec_spec.rb +++ b/spec/ruby_gem_release/rspec_spec.rb @@ -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)) }