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 @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 60 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/workflow/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 13 additions & 0 deletions examples/lifecycle_hooks/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 2 additions & 16 deletions examples/lifecycle_hooks/Rakefile
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions examples/lifecycle_hooks/release_workflow.rb
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions examples/lifecycle_hooks/spec/release_spec.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions lib/ruby-gem-release/rspec.rb
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions lib/ruby-gem-release/rspec/project.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions lib/ruby-gem-release/rspec/provider.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading