From 65ea5a100029adeb76d0288a502521f7a29bfcf1 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 11 Nov 2026 06:37:23 -0500 Subject: [PATCH] Add reusable release spec helpers and examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + DESIGN.md | 2 + README.md | 1 + docs/testing.md | 60 ++++++++++++ docs/usage.md | 2 + docs/workflow/hooks.md | 2 +- examples/lifecycle_hooks/README.md | 13 +++ examples/lifecycle_hooks/Rakefile | 18 +--- examples/lifecycle_hooks/release_workflow.rb | 26 +++++ examples/lifecycle_hooks/spec/release_spec.rb | 46 +++++++++ lib/ruby-gem-release/rspec.rb | 16 +++ lib/ruby-gem-release/rspec/project.rb | 97 +++++++++++++++++++ lib/ruby-gem-release/rspec/provider.rb | 21 ++++ lib/ruby-gem-release/rspec/publication.rb | 52 ++++++++++ spec/fixtures/portable_consumer_support.rb | 16 +-- spec/integration/rspec_example_spec.rb | 4 + .../workflow_portable_release_spec.rb | 45 +++------ spec/ruby_gem_release/rspec_spec.rb | 80 +++++++++++++++ 18 files changed, 441 insertions(+), 61 deletions(-) create mode 100644 docs/testing.md create mode 100644 examples/lifecycle_hooks/README.md create mode 100644 examples/lifecycle_hooks/release_workflow.rb create mode 100644 examples/lifecycle_hooks/spec/release_spec.rb create mode 100644 lib/ruby-gem-release/rspec.rb create mode 100644 lib/ruby-gem-release/rspec/project.rb create mode 100644 lib/ruby-gem-release/rspec/provider.rb create mode 100644 lib/ruby-gem-release/rspec/publication.rb create mode 100644 spec/integration/rspec_example_spec.rb create mode 100644 spec/ruby_gem_release/rspec_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f478acf..7273a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.5.2 (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). - Your contribution here. diff --git a/DESIGN.md b/DESIGN.md index f0d23ab..f098e9b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,6 +71,8 @@ Source CI runs `rake spec:unit`; installed-gem CI runs `rake spec:integration`. Real-process consumers retain success, failure/recovery, empty-release rejection, custom registry, and credential propagation. Portable consumers also verify environment transport, state, and installed-library loading without Actions variables. Git pushes use local bare repositories; publication boundaries are isolated and unexpected network requests fail. These tests do not validate live Actions authentication or artifact uploading. Profile harness changes rather than enforcing timing thresholds. +Optional `ruby-gem-release/rspec` support exposes disposable Git fixtures, isolated Rake processes, a provider for cross-process exports, and local registry and hosting adapters without adding an RSpec runtime dependency. Projects exercise their real workflow configuration with explicit test adapters; the helper does not rewrite production tasks or sandbox arbitrary callbacks. The lifecycle-hook example shares its workflow builder between its Rakefile and complete consumer spec. + ## Self-Release and Artwork The manual `publish.yml` caller uses the root Rakefile and Ruby 4.0; merges and schedules do not publish. Every dispatch requires release authorization. Its project-local `ReleaseVersion` updates README and documentation pins during the next development iteration, after the release tag exists. See the [self-release walkthrough](examples/self_release/README.md). diff --git a/README.md b/README.md index 1dbfb37..00933ec 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Replace only what you need. The [standard workflow](docs/workflow/ruby_gem.md) p - **Release hosting:** Replace or disable [GitHub Release creation](docs/workflow/ruby_gem.md#hosting) without changing package publication or Git operations. - **Execution platforms:** Supply an [execution provider](docs/execution_provider.md) for environment transport, metadata, temporary storage, and authentication services. GitHub Actions is the only built-in external platform; another platform needs its own adapter and pipeline. - **Actions integration:** Override commands, inputs, and timezone through the [Actions Reference](docs/actions/release.md), or use the [Composite Action](docs/actions/action.md) inside a caller-owned job. +- **Testing customizations:** Use optional [RSpec helpers and local publication adapters](docs/testing.md) to verify project-specific release behavior and catch regressions in release-managed files. Execution platform, registry, and hosting are independent choices. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..d647756 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,60 @@ +# Testing Custom Workflows + +Tests for a project's `ruby-gem-release` configuration should exercise the installed gem and the project's own release tasks. A focused test can verify a custom version object or callback, while a disposable consumer test can catch integration regressions across the workflow. + +## Isolate the Release + +Load the optional `ruby-gem-release/rspec` helper and include `RubyGemRelease::RSpec` in an example group. It does not add an RSpec runtime dependency or change production release tasks. + +```ruby +require 'ruby-gem-release/rspec' + +RSpec.describe 'My release configuration' do + include RubyGemRelease::RSpec + + it 'updates release documentation' do + with_release_project do |project| + # Write the gemspec, version, changelog, README, and test Rakefile here. + project.commit! + project.release!(version: '0.2.0') + + expect(project.remote_file('v0.2.0', 'README.md')).to include('stable release') + expect(project.remote_file('master', 'README.md')).to include('next release') + end + end +end +``` + +See the [complete spec](../examples/lifecycle_hooks/spec/release_spec.rb) for all fixture files. It exercises the [same workflow builder](../examples/lifecycle_hooks/release_workflow.rb) as the example's production Rakefile, so deleting a hook breaks the spec instead of leaving a separate test configuration working. + +`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. + +`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. + +## 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: + +```ruby +require 'ruby-gem-release/rspec' + +ProjectReleaseWorkflow.build( + provider: RubyGemRelease::RSpec::Provider.new, + destination: RubyGemRelease::RSpec::Destination.new, + hosting: RubyGemRelease::RSpec::Hosting.new +).install_tasks +``` + +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. + +These are explicit replacements, not a sandbox: custom callbacks can still make external calls. Keep all Git remotes local, replace every external publication boundary, and never dispatch a publishing Actions workflow from these specs. + +## Check Project-Specific Behavior + +Test the behavior your project added, not only that release tasks are registered. Assert the exact README content in both the release tag and the next development commit. This catches a later change that removes the README update even though the gem's standard release still succeeds. + +Also cover failures and reruns where they matter to the customization: assert that rejected publication leaves the remote release ref unchanged, expected recovery artifacts remain available, and repeating a successful operation does not move immutable release tags or change already-prepared files. + +The [self-release example](../examples/self_release/README.md) also has a [focused version spec](../spec/release_version_spec.rb) covering README and documentation pins. The [portable integration specs](../spec/integration/workflow_portable_release_spec.rb) reuse the helper for more extensive cross-process and failure coverage. diff --git a/docs/usage.md b/docs/usage.md index 14a1237..719b78f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,6 +42,8 @@ Use [lifecycle hooks](workflow/hooks.md) to add checks or project edits without The package uses `GEMSPEC_PATH` or the only root gemspec. Repository, branch, and artifact directory use the existing checkout and workflow defaults. Missing or ambiguous selections fail instead of guessing. +When adding project-specific release customizations, use optional `ruby-gem-release/rspec` helpers to test the real workflow in a disposable consumer repository and inspect its commits and release-managed files; see [Testing Custom Workflows](testing.md). + ## Object Overrides Override `package:`, `destination:`, `git:`, `hosting:`, or `provider:` without changing the sequence. For example, disable hosted releases while retaining RubyGems publication: diff --git a/docs/workflow/hooks.md b/docs/workflow/hooks.md index b109a27..a717f6a 100644 --- a/docs/workflow/hooks.md +++ b/docs/workflow/hooks.md @@ -43,4 +43,4 @@ Existing `success:` and `failure:` callbacks run inside their stages, between be Hook exceptions keep their original class and message and are recorded as `failure.error.stage`, `.hook`, and `.hook_index`. Built-in failures have no hook timing or index. `failure.phase` remains the enclosing task or Actions step. -See the [consumer example](../../examples/lifecycle_hooks/Rakefile) for project-local edits using the standard workflow. +See the [consumer example](../../examples/lifecycle_hooks/Rakefile) for project-local edits using the standard workflow, its [complete release spec](../../examples/lifecycle_hooks/spec/release_spec.rb), and [testing helpers](../testing.md). diff --git a/examples/lifecycle_hooks/README.md b/examples/lifecycle_hooks/README.md new file mode 100644 index 0000000..c9d3b94 --- /dev/null +++ b/examples/lifecycle_hooks/README.md @@ -0,0 +1,13 @@ +# Lifecycle Hook Example + +The [Rakefile](Rakefile) installs the [project workflow](release_workflow.rb), which uses [release hooks](release_notes.rb) to update this README and the changelog. + +## Stable Release + +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. + +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. diff --git a/examples/lifecycle_hooks/Rakefile b/examples/lifecycle_hooks/Rakefile index 37b0ca0..dd4e9be 100644 --- a/examples/lifecycle_hooks/Rakefile +++ b/examples/lifecycle_hooks/Rakefile @@ -1,19 +1,5 @@ # frozen_string_literal: true -require 'ruby-gem-release/workflow/ruby_gem' -require 'ruby-gem-release/changelog' -require_relative 'release_notes' +require_relative 'release_workflow' -workflow = RubyGemRelease::Workflow::RubyGem.new( - version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), - changelog: RubyGemRelease::Changelog::KeepAChangelog.new -) - -notes = ProjectReleaseNotes.new -workflow.before(:validate) { |ctx| notes.validate!(ctx) } -workflow.before(:prepare_release) { |ctx| notes.clean_placeholders!(ctx) } -workflow.after(:prepare_release) { |ctx| notes.update_stable_readme!(ctx) } -workflow.after(:prepare_next) { |ctx| notes.restore_template!(ctx) } -workflow.after(:prepare_next) { |ctx| notes.update_development_readme!(ctx) } - -workflow.install_tasks +ProjectReleaseWorkflow.build.install_tasks diff --git a/examples/lifecycle_hooks/release_workflow.rb b/examples/lifecycle_hooks/release_workflow.rb new file mode 100644 index 0000000..26b13ce --- /dev/null +++ b/examples/lifecycle_hooks/release_workflow.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'ruby-gem-release/workflow/ruby_gem' +require 'ruby-gem-release/changelog' +require_relative 'release_notes' + +module ProjectReleaseWorkflow + def self.build(**) + workflow = RubyGemRelease::Workflow::RubyGem.new( + version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), + changelog: RubyGemRelease::Changelog::KeepAChangelog.new, + ** + ) + add_hooks(workflow) + workflow + end + + def self.add_hooks(workflow) + notes = ProjectReleaseNotes.new + workflow.before(:validate) { |ctx| notes.validate!(ctx) } + workflow.before(:prepare_release) { |ctx| notes.clean_placeholders!(ctx) } + workflow.after(:prepare_release) { |ctx| notes.update_stable_readme!(ctx) } + workflow.after(:prepare_next) { |ctx| notes.restore_template!(ctx) } + workflow.after(:prepare_next) { |ctx| notes.update_development_readme!(ctx) } + end +end diff --git a/examples/lifecycle_hooks/spec/release_spec.rb b/examples/lifecycle_hooks/spec/release_spec.rb new file mode 100644 index 0000000..b9c512c --- /dev/null +++ b/examples/lifecycle_hooks/spec/release_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rspec' +require 'ruby-gem-release/rspec' + +RSpec.describe 'Project release customizations' do + include RubyGemRelease::RSpec + + 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.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.write('example.gemspec', <<~RUBY) + require_relative 'lib/example/version' + Gem::Specification.new do |spec| + spec.name = 'example' + spec.version = VERSION + spec.authors = ['Fixture'] + spec.summary = 'Release test fixture' + spec.files = ['lib/example/version.rb', 'README.md'] + 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! + + project.release!(version: '0.2.0') + + 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') + expect(project.git('status', '--porcelain')).to eq('') + end + end +end diff --git a/lib/ruby-gem-release/rspec.rb b/lib/ruby-gem-release/rspec.rb new file mode 100644 index 0000000..0403a59 --- /dev/null +++ b/lib/ruby-gem-release/rspec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'rspec/project' +require_relative 'rspec/provider' +require_relative 'rspec/publication' + +module RubyGemRelease + module RSpec + def with_release_project + Dir.mktmpdir('ruby-gem-release-') do |directory| + yield Project.new(directory: directory) + end + end + end +end diff --git a/lib/ruby-gem-release/rspec/project.rb b/lib/ruby-gem-release/rspec/project.rb new file mode 100644 index 0000000..765d6b3 --- /dev/null +++ b/lib/ruby-gem-release/rspec/project.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'open3' + +module RubyGemRelease + module RSpec + class Project + attr_reader :directory, :checkout, :remote, :environment + + def initialize(directory:) + @directory = File.expand_path(directory) + @checkout = File.join(@directory, 'checkout') + @remote = File.join(@directory, 'remote.git') + @environment = build_environment + FileUtils.mkdir_p([@checkout, @environment.fetch('RELEASE_DATA_DIR')]) + initialize_git + end + + def write(path, content) + destination = File.expand_path(path, @checkout) + raise ArgumentError, 'fixture files must be inside the checkout' unless destination.start_with?("#{@checkout}/") + + FileUtils.mkdir_p(File.dirname(destination)) + File.write(destination, content) + end + + def commit!(message = 'Prepare release fixture') + git('add', '.') + git('commit', '--quiet', '-m', message) + git('push', '--quiet', '-u', 'origin', 'master') + end + + def git(*) + output, error, status = Open3.capture3(@environment, 'git', *, chdir: @checkout, unsetenv_others: true) + raise "#{output}\n#{error}" unless status.success? + + output.strip + end + + def remote_file(ref, path) + git('--git-dir', @remote, 'show', "#{ref}:#{path}") + end + + def run_task(name, chdir: @checkout) + output, error, status = Open3.capture3(@environment, Gem.ruby, '-S', 'bundle', 'exec', 'rake', name, chdir: chdir, unsetenv_others: true) + import_environment + return output if status.success? + + @environment['RELEASE_TEST_FAILURES'] = JSON.generate([name]) + raise "#{output}\n#{error}" + end + + def release!(version: '', test_task: nil) + @environment['INPUT_VERSION'] = version + %w[release:workflow:capture release:validate release:prepare release:workflow:resolve].each { |name| run_task(name) } + run_task(test_task) if test_task + %w[release:workflow:checkpoint release:publish release:bump release:success].each { |name| run_task(name) } + end + + private + + def initialize_git + git('init', '--bare', '--quiet', '-b', 'master', @remote) + git('init', '--quiet', '-b', 'master') + git('config', 'user.name', 'Release Fixture') + git('config', 'user.email', 'release-fixture@example.com') + git('remote', 'add', 'origin', @remote) + end + + def build_environment + ENV.to_h.slice('PATH', 'HOME', 'TMPDIR', 'LANG').merge( + ruby_environment, + 'GIT_CONFIG_NOSYSTEM' => '1', 'GIT_CONFIG_GLOBAL' => File::NULL, + 'RELEASE_DATA_DIR' => File.join(@directory, 'artifacts'), + 'RELEASE_TEST_ENVIRONMENT' => File.join(@directory, 'environment.json'), + 'SOURCE_REF' => 'refs/heads/master', 'DEFAULT_BRANCH' => 'master', 'TZ' => 'UTC' + ) + end + + def ruby_environment + { + 'BUNDLE_GEMFILE' => File.expand_path(ENV.fetch('RELEASE_CONSUMER_GEMFILE', ENV.fetch('BUNDLE_GEMFILE', 'Gemfile'))), + 'RUBYLIB' => ENV.fetch('RELEASE_CONSUMER_LIB', File.expand_path('../..', __dir__)), + 'GEM_HOME' => ENV.fetch('RELEASE_CONSUMER_GEM_HOME', Gem.dir), + 'GEM_PATH' => ENV.fetch('RELEASE_CONSUMER_GEM_PATH', Gem.path.join(File::PATH_SEPARATOR)) + } + end + + def import_environment + path = @environment.fetch('RELEASE_TEST_ENVIRONMENT') + @environment.merge!(JSON.parse(File.read(path))) if File.file?(path) + end + end + end +end diff --git a/lib/ruby-gem-release/rspec/provider.rb b/lib/ruby-gem-release/rspec/provider.rb new file mode 100644 index 0000000..4bf60a1 --- /dev/null +++ b/lib/ruby-gem-release/rspec/provider.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../execution_provider' + +module RubyGemRelease + module RSpec + class Provider < ExecutionProvider + def export(values) + super + path = fetch('RELEASE_TEST_ENVIRONMENT') + previous = File.file?(path) ? JSON.parse(File.read(path)) : {} + File.write(path, JSON.generate(previous.merge(values))) + end + + def failed_steps + JSON.parse(fetch('RELEASE_TEST_FAILURES', '[]')) + end + end + end +end diff --git a/lib/ruby-gem-release/rspec/publication.rb b/lib/ruby-gem-release/rspec/publication.rb new file mode 100644 index 0000000..d2cb3bb --- /dev/null +++ b/lib/ruby-gem-release/rspec/publication.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'digest' +require 'fileutils' +require_relative '../destination' + +module RubyGemRelease + module RSpec + module Publication + def publish!(release, package:) + return if published?(release, package: package) + raise @failure if @failure + + FileUtils.mkdir_p(@directory) + FileUtils.cp(package.artifact(release), publication_path(release)) + end + + def published?(release, package:) + path = publication_path(release) + return false unless File.file?(path) + raise 'Published checksum differs' unless Digest::SHA256.file(path).hexdigest == Digest::SHA256.file(package.artifact(release)).hexdigest + + true + end + + private + + def publication_path(release) + File.join(@directory, "#{release.version}.gem") + end + end + + class Destination < RubyGemRelease::Destination + include Publication + + def initialize(directory: ENV.fetch('RELEASE_DATA_DIR'), failure: nil) + super() + @directory = File.join(directory, 'registry') + @failure = failure + end + end + + class Hosting + include Publication + + def initialize(directory: ENV.fetch('RELEASE_DATA_DIR'), failure: nil) + @directory = File.join(directory, 'hosting') + @failure = failure + end + end + end +end diff --git a/spec/fixtures/portable_consumer_support.rb b/spec/fixtures/portable_consumer_support.rb index 4418736..b0090cd 100644 --- a/spec/fixtures/portable_consumer_support.rb +++ b/spec/fixtures/portable_consumer_support.rb @@ -3,6 +3,7 @@ require 'ruby-gem-release/workflow/ruby_gem' require 'ruby-gem-release/workflow/semantic_ruby_gem' require 'ruby-gem-release/changelog' +require 'ruby-gem-release/rspec' require 'digest' raise 'Unexpected Actions environment' if ENV.keys.any? { |name| name.start_with?('GITHUB_', 'RUNNER_', 'ACTIONS_', 'GH_') } @@ -37,19 +38,6 @@ def self.remote_tags output.lines.map(&:strip) end - class Provider < RubyGemRelease::ExecutionProvider - def export(values) - super - path = fetch('CONSUMER_ENVIRONMENT') - previous = File.file?(path) ? JSON.parse(File.read(path)) : {} - File.write(path, JSON.generate(previous.merge(values))) - end - - def failed_steps - JSON.parse(fetch('CONSUMER_FAILURES', '[]')) - end - end - class Destination < RubyGemRelease::Destination def authenticate!(**) PortableConsumer.record('authenticate') @@ -153,7 +141,7 @@ def self.workflow_options(changelog) { version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), changelog: changelog, - provider: Provider.new, + provider: RubyGemRelease::RSpec::Provider.new, destination: Destination.new, hosting: Hosting.new, **callback_options diff --git a/spec/integration/rspec_example_spec.rb b/spec/integration/rspec_example_spec.rb new file mode 100644 index 0000000..d09ff18 --- /dev/null +++ b/spec/integration/rspec_example_spec.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../examples/lifecycle_hooks/spec/release_spec' diff --git a/spec/integration/workflow_portable_release_spec.rb b/spec/integration/workflow_portable_release_spec.rb index 8ff9508..288e609 100644 --- a/spec/integration/workflow_portable_release_spec.rb +++ b/spec/integration/workflow_portable_release_spec.rb @@ -4,25 +4,18 @@ require 'open3' require 'tmpdir' require 'rubygems/package' +require 'ruby-gem-release/rspec' RSpec.describe 'Provider-neutral release lifecycle' do let(:directory) { Dir.mktmpdir } let(:runner) { { checkout: 'checkout' } } let(:consumer_rakefile) { 'spec/fixtures/portable_consumer.rb' } - let(:remote) { File.join(directory, 'remote.git') } + let(:project) { RubyGemRelease::RSpec::Project.new(directory: directory) } let(:environment) do - ENV.to_h.slice('PATH', 'HOME', 'TMPDIR', 'LANG').merge( - 'BUNDLE_GEMFILE' => ENV.fetch('RELEASE_CONSUMER_GEMFILE', File.expand_path('Gemfile')), - 'RUBYLIB' => ENV.fetch('RELEASE_CONSUMER_LIB', File.expand_path('lib')), + project.environment.merge!( 'RELEASE_TEST_LIB' => ENV.fetch('RELEASE_CONSUMER_LIB', File.expand_path('lib')), - 'GEM_HOME' => ENV.fetch('RELEASE_CONSUMER_GEM_HOME', Gem.dir), - 'GEM_PATH' => ENV.fetch('RELEASE_CONSUMER_GEM_PATH', Gem.path.join(File::PATH_SEPARATOR)), - 'GIT_CONFIG_NOSYSTEM' => '1', 'GIT_CONFIG_GLOBAL' => File::NULL, - 'RELEASE_DATA_DIR' => File.join(directory, 'artifacts'), - 'CONSUMER_ENVIRONMENT' => File.join(directory, 'environment.json'), - 'SOURCE_REF' => 'refs/heads/master', 'DEFAULT_BRANCH' => 'master', 'INPUT_VERSION' => '0.2.0', 'INPUT_GEMSPEC_PATH' => 'example.gemspec', - 'RELEASE_TEST_REMOTE' => remote, 'TZ' => 'UTC' + 'RELEASE_TEST_REMOTE' => remote ) end @@ -30,6 +23,10 @@ def checkout File.join(directory, runner.fetch(:checkout)) end + def remote + project.remote + end + before do FileUtils.mkdir_p(File.join(checkout, 'lib/example')) FileUtils.mkdir_p(environment.fetch('RELEASE_DATA_DIR')) @@ -47,31 +44,19 @@ def checkout File.write(File.join(checkout, 'CHANGELOG.md'), "# Changelog\n\n## [Unreleased]\n\n- Change.\n") FileUtils.cp('spec/fixtures/portable_consumer_support.rb', File.join(checkout, 'portable_consumer_support.rb')) FileUtils.cp(consumer_rakefile, File.join(checkout, 'Rakefile')) - run_git('init', '--bare', '--quiet', '-b', 'master', remote) - run_git('init', '--quiet', '-b', 'master', checkout) run_git('-C', checkout, 'add', '.') run_git('-C', checkout, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.com', 'commit', '--quiet', '-m', 'Initial') - run_git('-C', checkout, 'remote', 'add', 'origin', remote) run_git('-C', checkout, 'push', '--quiet', '-u', 'origin', 'master') end after { FileUtils.remove_entry(directory) } def run_git(*arguments) - output, error, status = Open3.capture3('git', *arguments) - raise error unless status.success? - - output.strip + project.git(*arguments) end def run_task(name) - output, error, status = Open3.capture3(environment, 'bundle', 'exec', 'rake', name, chdir: checkout, unsetenv_others: true) - path = environment.fetch('CONSUMER_ENVIRONMENT') - environment.merge!(JSON.parse(File.read(path))) if File.exist?(path) - return if status.success? - - environment['CONSUMER_FAILURES'] = JSON.generate([name]) - raise "#{output}\n#{error}" + project.run_task(name, chdir: checkout) end def prepare_release @@ -90,8 +75,8 @@ def fresh_runner def reset_invocation FileUtils.mkdir_p(environment.fetch('RELEASE_DATA_DIR')) %w[RELEASE_STATE_FILE RELEASE_VERSION GEMSPEC_VERSION RELEASE_DATE SOURCE_COMMIT - CONSUMER_FAIL_PUBLICATION CONSUMER_FORBID_ANALYSIS GEMSPEC_PATH].each { |key| environment.delete(key) } - File.delete(environment.fetch('CONSUMER_ENVIRONMENT')) if File.file?(environment.fetch('CONSUMER_ENVIRONMENT')) + CONSUMER_FAIL_PUBLICATION CONSUMER_FORBID_ANALYSIS GEMSPEC_PATH RELEASE_TEST_FAILURES].each { |key| environment.delete(key) } + File.delete(environment.fetch('RELEASE_TEST_ENVIRONMENT')) if File.file?(environment.fetch('RELEASE_TEST_ENVIRONMENT')) end def artifact(name) @@ -128,7 +113,7 @@ def events success = events.find { |event| event['event'] == 'success' } expect(success).to include('version' => '0.2.0', 'gemspec_version' => '0.1.0', 'next_version' => '0.2.1', 'context_class' => 'RubyGemRelease::ExecutionContext', 'step' => 'release:success', - 'provider' => 'PortableConsumer::Provider', 'run' => nil, 'steps' => {}) + 'provider' => 'RubyGemRelease::RSpec::Provider', 'run' => nil, 'steps' => {}) invocations = events.select { |event| event['event'] == 'invocation' } expect(invocations.map { |event| event.fetch('task') }).to eq(%w[release:workflow:capture release:validate release:prepare release:workflow:resolve test release:workflow:checkpoint release:publish release:bump release:success]) expect(invocations.map { |event| event.fetch('pid') }.uniq.size).to eq(invocations.size) @@ -147,7 +132,7 @@ def events failure = events.find { |event| event['event'] == 'failure' } expect(failure).to include('phase' => 'release:publish', 'step' => 'release:failure', 'error' => 'Registry rejected release', 'version' => '0.2.0', 'next_version' => nil, 'context_class' => 'RubyGemRelease::ExecutionContext', - 'provider' => 'PortableConsumer::Provider') + 'provider' => 'RubyGemRelease::RSpec::Provider') expect(events.map { |event| event.fetch('event') }).not_to include('hosting', 'success') fresh_runner prepare_release @@ -204,7 +189,7 @@ def events end context 'with semantic releases' do - let(:environment) { super().merge('CONSUMER_SEMANTIC' => 'true', 'INPUT_VERSION' => '') } + let(:environment) { super().merge!('CONSUMER_SEMANTIC' => 'true', 'INPUT_VERSION' => '') } before do run_git('-C', checkout, 'tag', 'v0.4.0') diff --git a/spec/ruby_gem_release/rspec_spec.rb b/spec/ruby_gem_release/rspec_spec.rb new file mode 100644 index 0000000..f386ac0 --- /dev/null +++ b/spec/ruby_gem_release/rspec_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'ruby-gem-release/rspec' + +RSpec.describe RubyGemRelease::RSpec do + include described_class + + it 'isolates the fixture from inherited release and Git state and cleans up on failure' do + directory = nil + original = ENV.to_h + ENV.update('GITHUB_ACTIONS' => 'true', 'RUNNER_TEMP' => '/outer/runner', 'RELEASE_STATE_FILE' => '/outer/state', + 'GIT_DIR' => '/outer/git', 'RUBYGEMS_API_KEY' => 'outer-key') + expect do + with_release_project do |project| + directory = project.directory + expect(project.environment.keys).not_to include('GITHUB_ACTIONS', 'RUNNER_TEMP', 'RELEASE_STATE_FILE', 'GIT_DIR', 'RUBYGEMS_API_KEY') + expect(project.git('remote', 'get-url', 'origin')).to eq(project.remote) + raise 'Fixture failure' + end + end.to raise_error('Fixture failure') + expect(File).not_to exist(directory) + ensure + ENV.replace(original) + end + + it 'keeps fixture files inside the checkout and reports failed Git commands' do + with_release_project do |project| + expect { project.write('../escape', 'no') }.to raise_error(ArgumentError, /inside the checkout/) + expect { project.git('show', 'missing') }.to raise_error(RuntimeError, /missing/) + end + end + + it 'transports exported values and failed steps between separate Rake processes' do + with_release_project do |project| + project.write('Rakefile', <<~RUBY) + require 'ruby-gem-release/rspec' + task(:export) { RubyGemRelease::RSpec::Provider.new.export('FIXTURE_VALUE' => 'exported') } + task(:check) { raise 'export lost' unless ENV.fetch('FIXTURE_VALUE') == 'exported' } + task(:reject) { raise 'Rejected fixture task' } + task(:failure) { puts RubyGemRelease::RSpec::Provider.new.failed_steps } + RUBY + project.run_task('export') + project.run_task('check') + expect { project.run_task('reject') }.to raise_error(RuntimeError, /Rejected fixture task/) + expect(project.run_task('failure')).to include('reject') + 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)) } + + it 'stores an exact archive locally, accepts repetition, and rejects checksum conflicts' do + with_release_project do |project| + artifact = File.join(project.directory, 'example.gem') + File.write(artifact, 'release archive') + package = instance_double(RubyGemRelease::Package::RubyGem, artifact: artifact) + publication = adapter.new(directory: project.directory) + expect(publication.published?(release, package: package)).to be(false) + 2.times { publication.publish!(release, package: package) } + expect(publication.published?(release, package: package)).to be(true) + File.write(artifact, 'different archive') + expect { publication.publish!(release, package: package) }.to raise_error(RuntimeError, /checksum differs/) + end + end + + it 'can reject publication without recording success' do + with_release_project do |project| + artifact = File.join(project.directory, 'example.gem') + File.write(artifact, 'release archive') + package = instance_double(RubyGemRelease::Package::RubyGem, artifact: artifact) + publication = adapter.new(directory: project.directory, failure: 'Rejected test publication') + expect { publication.publish!(release, package: package) }.to raise_error(RuntimeError, /Rejected test publication/) + expect(publication.published?(release, package: package)).to be(false) + end + end + end + end +end