From 1a7febb919a423728cceb583de9827f694e354e4 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Tue, 10 Nov 2026 20:13:29 -0500 Subject: [PATCH] Add ordered release lifecycle hooks Expose trusted preparation boundaries, preserve existing callbacks, identify hook failures, and document stage execution while retaining the existing checkpoint and publication flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + DESIGN.md | 4 +- README.md | 1 + UPGRADING.md | 6 +- docs/execution_context.md | 8 +- docs/package/ruby_gem.md | 4 + docs/tasks.md | 2 + docs/usage.md | 10 +- docs/workflow/hooks.md | 46 +++++ docs/workflow/ruby_gem.md | 14 +- docs/workflow/semantic_ruby_gem.md | 2 + examples/lifecycle_hooks/Rakefile | 19 ++ examples/lifecycle_hooks/release_notes.rb | 52 ++++++ lib/ruby-gem-release/execution_context.rb | 21 ++- lib/ruby-gem-release/release_failure.rb | 18 +- lib/ruby-gem-release/tasks.rb | 58 +++++- lib/ruby-gem-release/workflow/failure.rb | 4 +- lib/ruby-gem-release/workflow/hooks.rb | 57 ++++++ lib/ruby-gem-release/workflow/ruby_gem.rb | 89 ++++++--- .../workflow/semantic_ruby_gem.rb | 18 +- spec/fixtures/portable_consumer.rb | 147 +-------------- spec/fixtures/portable_consumer_hooks.rb | 27 +++ spec/fixtures/portable_consumer_support.rb | 175 ++++++++++++++++++ .../workflow_portable_release_spec.rb | 51 ++++- spec/ruby_gem_release/tasks_callbacks_spec.rb | 7 + .../workflow/ruby_gem_spec.rb | 174 +++++++++++++++++ .../workflow/semantic_ruby_gem_spec.rb | 27 ++- 27 files changed, 832 insertions(+), 210 deletions(-) create mode 100644 docs/workflow/hooks.md create mode 100644 examples/lifecycle_hooks/Rakefile create mode 100644 examples/lifecycle_hooks/release_notes.rb create mode 100644 lib/ruby-gem-release/workflow/hooks.rb create mode 100644 spec/fixtures/portable_consumer_hooks.rb create mode 100644 spec/fixtures/portable_consumer_support.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a7250..9470d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.5.1 (Next) +- [#49](https://github.com/dblock/ruby-gem-release/pull/49): Add ordered lifecycle hooks to standard and semantic workflows, trusted preparation hooks, structured hook failure details, a consumer customization example, and real-Git coverage - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.5.0 (2026/11/10) diff --git a/DESIGN.md b/DESIGN.md index d4d6981..f0d23ab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,7 @@ Execution platform, registry, and hosting are independent choices. Built-in exte Both interfaces install dependencies, capture context, validate, prepare files, resolve the version, test, checkpoint and preserve artifacts, publish, prepare the next development iteration, and notify success. Validation raises on invalid state, including nothing to release; normal completion succeeds, while returning `false` also fails. Errors stop downstream operations and reach the failure callback after successful Ruby setup. -The standard publication callback commits prepared edits, builds reproducibly, tags locally and creates a recovery bundle, verifies or uploads the gem, atomically pushes its commit and tag, and creates or updates the hosted release. Git checks for concurrent branch changes before publication and again before pushing. +The checkpoint task commits prepared edits, builds reproducibly, tags locally, and creates a recovery bundle. After artifacts are preserved, publication repeats commit, build, and tag preparation before verifying or uploading the gem, atomically pushing its commit and tag, and creating or updating the hosted release. Git checks for concurrent branch changes before publication and again before pushing. Every release action must be idempotent for the same checkout and inputs, without recovery flags, artifact discovery, restored context, or a completion journal. Tasks reconcile their own outputs; a fresh workflow uses its current checkout and inputs rather than discovering an older unfinished release. Registry publication and Git pushes cannot be atomic together, so both interfaces preserve artifacts before publication for manual inspection. See [rerunning releases](RELEASING.md#verify-and-recover). @@ -37,6 +37,8 @@ Capture rejects non-default branches and dirty checkouts after dependency instal Resolution validates the prepared version; publishing tasks enforce their own postconditions. RubyGems accepts an existing version only when its checksum matches. Git reuses matching release and development trees without moving tags or rewinding remote descendants. Hosting replaces differing metadata and assets. Development increments are anchored to the supplied release version, not a recorded completion marker. +Lifecycle hooks add checks and project edits without replacing the release sequence. Preparation hooks run inside trusted staging; other hooks validate or observe. See the [hook guide](docs/workflow/hooks.md). + Version objects update exactly one constant assignment without evaluating the file. Changelog objects use `Commonmarker` to find sections, then edit original source to preserve formatting. Handwritten formats reject empty notes before writing; malformed content fails rather than skipping. See [version](docs/version/ruby_constant.md), [changelog](docs/changelog/base.md), and [Git](docs/source_control/git.md) contracts. ## Contexts and State diff --git a/README.md b/README.md index e36616f..a440adb 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ For a real-world example of adding release automation to an existing gem, see [r Replace only what you need. The [standard workflow](docs/workflow/ruby_gem.md) preserves ordering; [Detailed Usage](docs/usage.md) provides file examples and complete manual callbacks. - **Project conventions:** Choose [version objects](docs/usage.md#version-objects) and [changelog formats](docs/usage.md#changelog-objects), including custom implementations. +- **Stage customization:** Add ordered [lifecycle hooks](docs/workflow/hooks.md) for validation, trusted preparation edits, publication observers, and notifications without replacing the pipeline. - **Release decisions and callbacks:** Customize [validation, preparation, publication, and notifications](docs/tasks.md). Callbacks receive an [execution context](docs/execution_context.md) with release data, step history, and the configured workflow. - **Semantic releases:** Opt into [Conventional Commits, release-history-based versions, and generated notes](docs/workflow/semantic_ruby_gem.md) without changing the execution platform or registry. - **Packages and registries:** Override [package construction](docs/package/ruby_gem.md) or supply a [registry destination](docs/destination.md) independently of hosted releases. diff --git a/UPGRADING.md b/UPGRADING.md index 10b32d1..592e0b6 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,7 +2,11 @@ Upgrade the gem and pinned workflow or action together. Apply the relevant changes below; see the linked PRs for details. -## Upgrading to 0.5.0 (Unreleased) +## Upgrading To 0.5.1 + +Additive [lifecycle hooks](docs/workflow/hooks.md) are available on both workflows without replacing existing callbacks. Existing success/failure callbacks and explicit replacement interfaces remain supported. Preparation hooks run inside trusted Git staging; publication order and build behavior are unchanged. + +## Upgrading To 0.5.0 From 0.4.x, update both the gem and pinned Actions reference. Install only one workflow or manual task configuration. diff --git a/docs/execution_context.md b/docs/execution_context.md index fc369cc..173c75b 100644 --- a/docs/execution_context.md +++ b/docs/execution_context.md @@ -13,7 +13,11 @@ end ## Release -`ctx.release` is the immutable [`Release`](release.md) value used by changelog, package, Git, and hosting operations. It can be `nil` in a failure callback when release capture did not complete. Other callbacks require valid release data. +`ctx.release` is the immutable [`Release`](release.md) value used by changelog, package, Git, and hosting operations. It can be `nil` in a failure callback when release capture did not complete, or in a before-capture hook. Other callbacks require valid release data. + +## Lifecycle Stage + +[Lifecycle hooks](workflow/hooks.md) also expose the stage, timing, and hook position as `ctx.stage`, `ctx.hook`, and `ctx.hook_index`. Failure hooks expose the original error as `ctx.failure`; ordinary callback contexts leave these fields `nil`. ## Step @@ -44,3 +48,5 @@ Without a state file, callbacks installed by the same `Tasks` instance share in- ## Failure `failure.phase` identifies the failed step, while `failure.context.step.id` identifies the currently executing failure callback. `failure.error` retains the captured exception interface. Release data may be absent, but execution metadata remains available after an early failure. Failure callbacks cannot run before Ruby setup completes. + +Captured workflow errors also expose `stage`, `hook`, and `hook_index`, identifying the built-in stage or particular failing hook across processes. These are absent when failure occurred outside an observed lifecycle stage. diff --git a/docs/package/ruby_gem.md b/docs/package/ruby_gem.md index dbb0d8b..95ff130 100644 --- a/docs/package/ruby_gem.md +++ b/docs/package/ruby_gem.md @@ -22,10 +22,14 @@ Builds use `SOURCE_DATE_EPOCH` for the gemspec date and archive timestamps. Work Reproduce a release from the same source commit: equivalent files in another commit can have a different timestamp. Toolchains, generated files, gemspecs, and signing inputs must also remain stable. No previous archive is needed; partial output is overwritten. `built?(release)` verifies an existing archive and manifest without building. +The standard Actions workflow builds at checkpoint and again during publication. Each build writes the same reproducible archive and digest manifest; direct builds overwrite prior output. + ## Artifact `artifact(release)` verifies the built name, version, and digest and returns a copy named `NAME-VERSION.gem`. Registry destinations and hosted releases use those exact bytes. +Verification checks the retained archive against the manifest and validates its gem metadata before publication. `artifact(release)` copies the verified canonical archive to its name-and-version path. + ## Errors Build failures, version mismatches, and changed artifacts raise. The package does not contact a registry or upload files; see [`Destination::RubyGems`](../destination/ruby_gems.md) for publication and duplicate-version safeguards. diff --git a/docs/tasks.md b/docs/tasks.md index 7025763..0fafe42 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -10,6 +10,8 @@ The composite action first invokes `release:workflow:configure` to validate and The provider constructs callback contexts: by default, the provider-neutral `ExecutionContext` locally or its [`GitHub`](execution_context/github.md) subclass on Actions. Explicit `provider:` injection overrides automatic detection. GitHub-specific step statuses and run fields are not part of the local context. +Standard workflows provide additive [lifecycle hooks](workflow/hooks.md) for checks and project edits. Manual callbacks replace task behavior and do not automatically get the standard workflow's inner hooks. + ## Validate `tasks.validate` checks release preconditions after context capture and before preparation edits. It receives an [ExecutionContext](execution_context.md) containing the same initial or explicitly selected release value as `prepare`. Raise on invalid state; normal completion, including `nil`, succeeds. Returning `false` also fails rather than skipping. Without a callback, validation passes. diff --git a/docs/usage.md b/docs/usage.md index a92d72d..14a1237 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,7 +30,7 @@ RubyGemRelease::Workflow::RubyGem.new( ).install_tasks ``` -This validates, prepares, builds once, publishes the verified gem, pushes commit and tag atomically, creates the hosted release, and prepares the next patch version. +This validates and prepares the release, publishes the verified gem, pushes the release commit and tag, creates the hosted release, and prepares the next patch version. The default releases the checked-in version; optional `version` selects another through the version object's `set!`. The initial version remains in the context. Custom preparation callbacks own their file updates. @@ -38,6 +38,8 @@ Configure optional `success:` and `failure:` lambdas on `Workflow::RubyGem.new` Before edits, `release:validate` requires pending notes or an existing heading for the selected release. Otherwise it raises, even with an explicit version. Override with a `validate:` lambda that raises on failure; returning `false` also fails. Without a changelog, validation passes. +Use [lifecycle hooks](workflow/hooks.md) to add checks or project edits without replacing callbacks. Preparation hooks run inside trusted Git staging; see the [example](../examples/lifecycle_hooks/Rakefile). + 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. ## Object Overrides @@ -56,7 +58,7 @@ Set `changelog: nil` for projects without a changelog; version incrementing and ## Custom Callbacks -This is the full equivalent of the standard workflow. Keep this ordering to preserve artifact identity, concurrency checks, and package-before-tag publication: +For a genuinely different sequence, configure replacement callbacks explicitly. Individual project customizations should use lifecycle hooks instead. Keep this ordering to preserve artifact identity, concurrency checks, and package-before-tag publication: ```ruby require 'ruby-gem-release/workflow/ruby_gem' @@ -86,9 +88,7 @@ RubyGemRelease::Tasks.install(workflow: workflow, package: package, destination: end tasks.publish do |ctx| release = ctx.release - git.commit!(release) - package.build!(release) - git.tag!(release) + workflow.checkpoint!(ctx) destination.publish!(release, package: package) git.push!(release) github.publish!(release, package: package) diff --git a/docs/workflow/hooks.md b/docs/workflow/hooks.md new file mode 100644 index 0000000..b109a27 --- /dev/null +++ b/docs/workflow/hooks.md @@ -0,0 +1,46 @@ +# Lifecycle Hooks + +Both the [standard](ruby_gem.md) and [semantic](semantic_ruby_gem.md) workflows support ordered `before` and `after` hooks. Register them before `install_tasks`: + +```ruby +workflow.before(:validate) { |ctx| validate_project_notes(ctx) } +workflow.before(:prepare_release) { |ctx| remove_contribution_placeholders(ctx) } +workflow.after(:prepare_release) { |ctx| update_stable_readme(ctx) } +workflow.after(:prepare_next) { |ctx| restore_template_and_development_readme(ctx) } + +workflow.install_tasks +``` + +For each stage, before hooks run in registration order, then the built-in operation, then after hooks in registration order if it succeeded. Hooks add behavior; they do not replace versioning, changelog, publication, Git, hosting, or validation. + +## Context + +Hooks receive an immutable execution context with the configured `ctx.workflow` and current `ctx.release`. `ctx.stage`, `ctx.hook`, and `ctx.hook_index` identify the stage, timing, and one-based position. Before capture, `ctx.release` is `nil`; semantic hooks see `ctx.analysis` and the selected version after validation. Next-version hooks see `ctx.release.next_version` after development preparation. + +## Order + +All stages below support both hook timings. This is the Actions sequence; a direct Rake task runs only its own stages. + +- Setup: `capture` → `validate` → `prepare_release` → `resolve_version`. The prepared-release `test` command follows; it is not a hook stage. +- Checkpoint: `checkpoint` wraps `commit_release` → `build_package` → `tag_release`. +- Publication: Actions preserve artifacts, then run `authenticate` → `commit_release` → `build_package` → `tag_release` → `publish_registry` → `push_release` → `publish_hosted_release` (if hosting is enabled). +- Next development: `prepare_next` → `commit_next` → `push_next` → `success`. +- Failure: `failure` runs through the existing failure-reporting task. + +Actions run the checkpoint before publication, so `commit_release`, `build_package`, and `tag_release` hooks run twice. Standalone `release:publish` authenticates and runs those three stages once. `push_release` pushes the release commit and tag; `push_next` pushes only the development commit. Artifact preservation and authentication are not part of the checkpoint. + +## Safe Edits and Retries + +Only `prepare_release` and `prepare_next` are file-editing stages. Both hook timings run inside `git.stage_changes!`, before the release or next-development commit. If a hook raises, later hooks and that stage's after hooks do not run; the release stops, but partial working files are not rolled back. + +Use other hooks to validate or observe. Publication rebuilds the package after the Actions checkpoint, so edits to generated artifacts may be replaced. Preserve existing publication ordering and concurrency checks when replacing workflow operations. + +Hooks may run again when a task or Actions job is retried; make them idempotent. Authentication hooks are skipped when the destination confirms publication, hosting hooks are skipped when hosting is disabled, and semantic development hooks are skipped when `bump: false`. Each Actions process reloads the Rakefile and registers hooks again; callbacks are not serialized. + +## Callbacks and Failures + +Existing `success:` and `failure:` callbacks run inside their stages, between before and after hooks. Use `before(:validate)` or `after(:validate)` to add checks; the existing `validate:` option still replaces validation. + +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. diff --git a/docs/workflow/ruby_gem.md b/docs/workflow/ruby_gem.md index 791e853..a254667 100644 --- a/docs/workflow/ruby_gem.md +++ b/docs/workflow/ruby_gem.md @@ -14,7 +14,7 @@ Default validation requires `released?(release.version)` or `pending?`. This per ## Package -The optional `package:` defaults to [`Package::RubyGem`](../package/ruby_gem.md), supplying capture, prepared-version checks, and reproducible builds. Implement `name`, `version`, `build!(release)`, `artifact(release)`, and `built?(release)` for equivalent verification and publication. `gemspec` remains optional for capture. +The optional `package:` defaults to [`Package::RubyGem`](../package/ruby_gem.md), supplying capture, prepared-version checks, and reproducible builds. Implement `version`, `build!(release)`, `artifact(release)`, and `built?(release)` for equivalent verification and publication. `gemspec` remains optional for capture. ## Destination @@ -38,12 +38,16 @@ The optional `hosting:` defaults to [`Hosting::GitHubRelease`](../hosting/github `install_tasks` registers preparation, publication, next development iteration, success, failure, and workflow-context tasks. It does not immediately release or change project files. Use it instead of manual `Tasks.install` callback configuration, not alongside another installation. -Preparation updates the changelog with the selected version. Checkpoint commits, builds, and tags locally before artifact preservation. Publication authenticates unless the destination already proves the exact artifact is published, repeats checkpoint, verifies or uploads the package, pushes commit and tag atomically, and updates hosting. The next development iteration calculates from `release.version`, updates the changelog, and reconciles its commit and push. +Preparation updates the changelog with the selected version. In Actions, the checkpoint commits, builds, and tags before artifacts are preserved. Publication then authenticates as needed, repeats those three operations, publishes the package, pushes the release commit and tag, and updates hosting. The next development iteration prepares, commits, and pushes the next version. -Pass optional `success:` and `failure:` callbacks to the constructor for result notifications. Success receives an [ExecutionContext](../execution_context.md) with the published release and recorded next development version after the next development iteration succeeds. Failure receives a `RubyGemRelease::ReleaseFailure` with `phase`, `error`, and `context` properties; `failure.context.release` may be absent. A captured Ruby exception is reconstructed with its original class name, message, and stack trace; failures only reported by GitHub Actions have no `error`. A failure callback error does not turn a failed workflow into a successful one. +Pass optional `success:` and `failure:` callbacks for notifications. Success receives an execution context after the next development iteration completes; failure receives a `RubyGemRelease::ReleaseFailure`. Early failures may not have a release context, and failure callbacks do not turn a failed workflow into a successful one. -Callbacks can access this configured workflow through `ctx.workflow`. Public readers expose the configured `version`, `changelog`, `package`, `destination`, `git`, `hosting`, and `provider` objects; they are reconstructed through the Rakefile in each Ruby process, not serialized across Actions steps. +Contexts expose the configured `ctx.workflow`. Each process reconstructs the workflow from the Rakefile; it is not serialized across Actions steps. + +## Lifecycle Hooks + +Use `before(stage)` and `after(stage)` hooks to add checks or project edits without replacing callbacks. Preparation hooks run inside trusted Git staging. See the [hook guide](hooks.md) for ordering, safety, and an example. ## Errors -Errors propagate through the tasks and stop the sequence. The default objects preserve concurrency checks, artifact verification, recovery bundles, and package-before-tag publication. Callbacks receive execution snapshots; the release value inside the failure context is `nil` if capture failed. Overrides must preserve the publication order and artifact identity. +Errors stop the sequence. Default objects preserve concurrency checks, artifact verification, recovery bundles, and package-before-tag ordering. A failure context may not have a release if capture failed; custom operations must preserve publication order and artifact identity. diff --git a/docs/workflow/semantic_ruby_gem.md b/docs/workflow/semantic_ruby_gem.md index 838375f..1998fad 100644 --- a/docs/workflow/semantic_ruby_gem.md +++ b/docs/workflow/semantic_ruby_gem.md @@ -12,6 +12,8 @@ RubyGemRelease::Workflow::SemanticRubyGem.new( Install only one workflow. Execution, package, destination, Git, hosting, and result callbacks remain independently replaceable. +Use the same [lifecycle hooks](hooks.md) without replacing semantic analysis or publication. After validation, hooks can inspect the selected decision and version. Development hooks run only when `bump: true`. + ## History and Versions Validation selects the highest reachable stable `vMAJOR.MINOR.PATCH` tag, not the nearest or newest. Annotated and lightweight tags work; unrelated, prerelease, metadata, and alternate-format tags are ignored. Shallow checkouts fail. Capture fetches tags and checks remote advancement; supply full history. diff --git a/examples/lifecycle_hooks/Rakefile b/examples/lifecycle_hooks/Rakefile new file mode 100644 index 0000000..37b0ca0 --- /dev/null +++ b/examples/lifecycle_hooks/Rakefile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'ruby-gem-release/workflow/ruby_gem' +require 'ruby-gem-release/changelog' +require_relative 'release_notes' + +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 diff --git a/examples/lifecycle_hooks/release_notes.rb b/examples/lifecycle_hooks/release_notes.rb new file mode 100644 index 0000000..34deac5 --- /dev/null +++ b/examples/lifecycle_hooks/release_notes.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ProjectReleaseNotes + CATEGORIES = %w[Added Changed Deprecated Removed Fixed Security].freeze + STABLE_PARAGRAPH = /^## Stable Release\n\n[^\n]+$/ + + def initialize(changelog: 'CHANGELOG.md', readme: 'README.md') + @changelog = changelog + @readme = readme + end + + def validate!(_ctx) + raise ArgumentError, 'expected exactly one stable release paragraph in README.md' unless File.read(@readme).scan(STABLE_PARAGRAPH).one? + end + + def clean_placeholders!(ctx) + return if ctx.workflow.changelog.released?(ctx.release.version) + + content = File.read(@changelog).sub(/(^## \[Unreleased\]\n)(.*?)(?=^## |\z)/m) do + heading = Regexp.last_match(1) + notes = Regexp.last_match(2).gsub(/^\* Your contribution here\.\n/, '') + notes = notes.gsub(/^### [^\n]+\n\n*(?=### |\z)/, '') + "#{heading}#{notes.rstrip}\n\n" + end + File.write(@changelog, content) + end + + def update_stable_readme!(ctx) + write_readme("You're reading the documentation for the stable release of the project, #{ctx.release.version}.") + end + + def restore_template!(_ctx) + content = File.read(@changelog) + empty_section = /^## \[Unreleased\]\n\n(?=## )/ + return unless content.match?(empty_section) + + categories = CATEGORIES.map { |category| "### #{category}\n\n* Your contribution here.\n\n" }.join + File.write(@changelog, content.sub(empty_section, "## [Unreleased]\n\n#{categories}")) + end + + def update_development_readme!(ctx) + release = ctx.release + write_readme("You're reading the documentation for the next release of the project, which should be #{release.next_version}. " \ + "The current stable release is #{release.version}.") + end + + private + + def write_readme(paragraph) + File.write(@readme, File.read(@readme).sub(STABLE_PARAGRAPH, "## Stable Release\n\n#{paragraph}")) + end +end diff --git a/lib/ruby-gem-release/execution_context.rb b/lib/ruby-gem-release/execution_context.rb index 663dd10..f2eecce 100644 --- a/lib/ruby-gem-release/execution_context.rb +++ b/lib/ruby-gem-release/execution_context.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require_relative 'execution_context/step' +require_relative 'release' module RubyGemRelease class ExecutionContext - attr_reader :release, :step, :steps, :workflow, :run, :analysis + attr_reader :release, :step, :steps, :workflow, :run, :analysis, :stage, :hook, :hook_index, :failure def self.step_id(task) task @@ -28,5 +29,23 @@ def record_next_version!(version) def record_analysis!(decision) @state.record_analysis!(decision) end + + def with(release: @release, analysis: @analysis, stage: nil, hook: nil, hook_index: nil, failure: @failure) + copy = dup + { release: release, analysis: analysis, stage: stage, hook: hook, hook_index: hook_index, failure: failure }.each do |name, value| + copy.instance_variable_set("@#{name}", value) + end + copy.freeze + end + + def refresh + snapshot = @state.snapshot + analysis = snapshot.fetch(:analysis) + release = if @release + Release.new(version: analysis&.version || @release.version, gemspec_version: @release.gemspec_version, date: @release.date, + next_version: snapshot.fetch(:next_version), notes: analysis&.notes || @release.notes) + end + with(release: release, analysis: analysis) + end end end diff --git a/lib/ruby-gem-release/release_failure.rb b/lib/ruby-gem-release/release_failure.rb index c6464b1..95f2e25 100644 --- a/lib/ruby-gem-release/release_failure.rb +++ b/lib/ruby-gem-release/release_failure.rb @@ -3,24 +3,30 @@ module RubyGemRelease class ReleaseFailure class CapturedError < StandardError - attr_reader :original_class + attr_reader :original_class, :stage, :hook, :hook_index - def self.capture(error) - new(original_class: error.class.name || error.class.to_s, message: error.message, backtrace: error.backtrace) + def self.capture(error, **details) + new(original_class: error.class.name || error.class.to_s, message: error.message, backtrace: error.backtrace, **details) end def self.from_h(attributes) - new(original_class: attributes.fetch('class'), message: attributes.fetch('message'), backtrace: attributes.fetch('backtrace')) + new(original_class: attributes.fetch('class'), message: attributes.fetch('message'), backtrace: attributes.fetch('backtrace'), + stage: attributes['stage'], hook: attributes['hook'], hook_index: attributes['hook_index']) end - def initialize(original_class:, message:, backtrace:) + def initialize(original_class:, message:, backtrace:, stage: nil, hook: nil, hook_index: nil) @original_class = original_class.freeze + @stage = stage&.to_sym + @hook = hook&.to_sym + @hook_index = hook_index super(message) set_backtrace(backtrace) end def to_h - { 'class' => original_class, 'message' => message, 'backtrace' => backtrace } + { 'class' => original_class, 'message' => message, 'backtrace' => backtrace }.merge( + { 'stage' => stage&.to_s, 'hook' => hook&.to_s, 'hook_index' => hook_index }.compact + ) end end diff --git a/lib/ruby-gem-release/tasks.rb b/lib/ruby-gem-release/tasks.rb index 3c28b11..49fa36c 100644 --- a/lib/ruby-gem-release/tasks.rb +++ b/lib/ruby-gem-release/tasks.rb @@ -108,11 +108,28 @@ def install_workflow_tasks def install_workflow_context_tasks install_checkpoint_task + install_capture_task + install_resolve_task task(:configure) { with_failure_capture('release:workflow:configure') { Workflow::Environment.new(provider: @provider).configure! } } - task(:capture) { with_failure_capture('release:workflow:capture') { Workflow::Environment.new(provider: @provider).capture!(package: @package) } } + end + + def install_capture_task + task(:capture) do + with_failure_capture('release:workflow:capture') do + capture_context = @provider.context(release: nil, task: 'release:workflow:capture', state: Workflow::State.new, workflow: @workflow) + lifecycle(:capture, capture_context, after_context: -> { context('release:workflow:capture', preparing: true) }) do + Workflow::Environment.new(provider: @provider).capture!(package: @package) + end + end + end + end + + def install_resolve_task task(:resolve) do with_failure_capture('release:workflow:resolve') do - Workflow::Environment.new(provider: @provider).resolve!(package: @package, destination: @destination, analysis: state.analysis) + lifecycle(:resolve_version, context('release:workflow:resolve', preparing: true), after_context: -> { context('release:workflow:resolve') }) do + Workflow::Environment.new(provider: @provider).resolve!(package: @package, destination: @destination, analysis: state.analysis) + end end end end @@ -134,20 +151,32 @@ def install_publish_task next unless @publish ctx = context('release:publish') - @destination.authenticate!(provider: @provider) unless @workflow&.published?(ctx.release) + lifecycle(:authenticate, ctx) { @destination.authenticate!(provider: @provider) } unless @workflow&.published?(ctx.release) @publish.call(ctx) end end end def install_result_tasks - desc 'Run the release success callback' - task :success do - with_failure_capture('release:success') { @success&.call(context('release:success')) } - end + install_success_task desc 'Run the release failure callback' task :failure do - @failure&.call(failure_context) + next unless @failure || lifecycle_hooks?(:failure) + + failure = failure_context + lifecycle(:failure, failure.context.with(failure: failure)) { @failure&.call(failure) } + end + end + + def install_success_task + desc 'Run the release success callback' + task :success do + next unless @success || lifecycle_hooks?(:success) + + with_failure_capture('release:success') do + ctx = context('release:success') + lifecycle(:success, ctx) { @success&.call(ctx) } + end end end @@ -245,10 +274,21 @@ def failure_snapshot def with_failure_capture(task) yield rescue StandardError => e - Workflow::Failure.capture!(@provider.step_id(task), e, provider: @provider) + details = @workflow.respond_to?(:failure_details) ? @workflow.failure_details(e) : {} + Workflow::Failure.capture!(@provider.step_id(task), e, provider: @provider, **details) raise end + def lifecycle(stage, ctx, **, &) + return yield unless @workflow.respond_to?(:run_stage) + + @workflow.run_stage(stage, ctx, **, &) + end + + def lifecycle_hooks?(stage) + @workflow.respond_to?(:hooks?) && @workflow.hooks?(stage) + end + def validate_context_versions(*versions) pattern = Version::RubyConstant::PATTERN versions.compact.each do |value| diff --git a/lib/ruby-gem-release/workflow/failure.rb b/lib/ruby-gem-release/workflow/failure.rb index c6676bd..9d5b852 100644 --- a/lib/ruby-gem-release/workflow/failure.rb +++ b/lib/ruby-gem-release/workflow/failure.rb @@ -14,10 +14,10 @@ def phase!(provider: ExecutionProvider.current) provider.export('RELEASE_FAILED_STEP_ID' => phase) end - def capture!(phase, error, provider: ExecutionProvider.current) + def capture!(phase, error, provider: ExecutionProvider.current, **details) return unless provider.directory - captured_error = ReleaseFailure::CapturedError.capture(error) + captured_error = ReleaseFailure::CapturedError.capture(error, **details) File.open(failure_file(provider), 'w', 0o600) do |file| file.write(JSON.generate('phase' => phase, 'error' => captured_error.to_h)) end diff --git a/lib/ruby-gem-release/workflow/hooks.rb b/lib/ruby-gem-release/workflow/hooks.rb new file mode 100644 index 0000000..ae893f6 --- /dev/null +++ b/lib/ruby-gem-release/workflow/hooks.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module RubyGemRelease + module Workflow + class Hooks + STAGES = %i[capture resolve_version validate prepare_release authenticate checkpoint commit_release build_package tag_release + publish_registry push_release publish_hosted_release prepare_next commit_next push_next success failure].freeze + + def initialize + @hooks = { before: {}, after: {} } + end + + def register(timing, stage, &block) + raise ArgumentError, "unknown lifecycle stage: #{stage.inspect}" unless STAGES.include?(stage) + raise ArgumentError, 'hook requires a block' unless block + + (@hooks.fetch(timing)[stage] ||= []) << block + end + + def registered?(stage) + @hooks.values.any? { |stages| !stages.fetch(stage, []).empty? } + end + + def run(stage, context, after_context: nil) + call_hooks(:before, stage, context) + result = yield + call_hooks(:after, stage, after_context ? after_context.call : context) unless @hooks.fetch(:after).fetch(stage, []).empty? + result + rescue StandardError => e + record_failure(e, stage: stage) + raise + end + + def failure_details(error) + @failure_error.equal?(error) ? @failure_details : {} + end + + private + + def call_hooks(timing, stage, context) + @hooks.fetch(timing).fetch(stage, []).each_with_index do |block, index| + block.call(context.with(stage: stage, hook: timing, hook_index: index + 1)) + rescue StandardError => e + record_failure(e, stage: stage, hook: timing, hook_index: index + 1) + raise + end + end + + def record_failure(error, **details) + return if @failure_error.equal?(error) + + @failure_error = error + @failure_details = details + end + end + end +end diff --git a/lib/ruby-gem-release/workflow/ruby_gem.rb b/lib/ruby-gem-release/workflow/ruby_gem.rb index bc3b627..5f73f9f 100644 --- a/lib/ruby-gem-release/workflow/ruby_gem.rb +++ b/lib/ruby-gem-release/workflow/ruby_gem.rb @@ -5,6 +5,7 @@ require_relative '../destination/ruby_gems' require_relative '../source_control/git' require_relative '../hosting/github_release' +require_relative 'hooks' module RubyGemRelease module Workflow @@ -28,6 +29,28 @@ def initialize(version:, changelog:, provider: ExecutionProvider.current, @failure = failure end + def before(stage, &) + hooks.register(:before, stage, &) + self + end + + def after(stage, &) + hooks.register(:after, stage, &) + self + end + + def run_stage(stage, context, **, &) + hooks.run(stage, context, **, &) + end + + def failure_details(error) + hooks.failure_details(error) + end + + def hooks?(stage) + hooks.registered?(stage) + end + def install_tasks Tasks.install(workflow: self, package: @package, destination: @destination, provider: @provider) do |tasks| install_callbacks(tasks) @@ -35,7 +58,7 @@ def install_tasks end def checkpoint!(context) - prepare_publication(context.release) + run_stage(:checkpoint, context) { prepare_publication(context) } end def published?(release) @@ -44,15 +67,25 @@ def published?(release) private + def hooks + @hooks ||= Hooks.new + end + def install_callbacks(tasks) - tasks.validate { |ctx| validate_release(ctx) } - tasks.prepare { |ctx| prepare_release(ctx.release) } - tasks.publish { |ctx| publish(ctx.release) } + tasks.validate { |ctx| validate_with_hooks(ctx) } + tasks.prepare { |ctx| prepare_release(ctx) } + tasks.publish { |ctx| publish(ctx) } tasks.bump { |ctx| prepare_next(ctx) } tasks.success(&@success) if @success tasks.failure(&@failure) if @failure end + def validate_with_hooks(ctx) + run_stage(:validate, ctx, after_context: -> { ctx.refresh }) do + raise ArgumentError, 'Release validation failed: callback returned false' if validate_release(ctx) == false + end + end + def validate_release(ctx) return @validate.call(ctx) if @validate @@ -61,46 +94,52 @@ def validate_release(ctx) raise ArgumentError, 'Nothing to release: no pending changelog notes' end - def prepare_release(release) + def prepare_release(ctx) @git.stage_changes! do - select_version!(release.version) unless @provider.fetch('INPUT_VERSION', '').empty? - @changelog&.release!(release.with_version(@version.read)) + run_stage(:prepare_release, ctx) { prepare_release_files(ctx.release) } end end + def prepare_release_files(release) + select_version!(release.version) unless @provider.fetch('INPUT_VERSION', '').empty? + @changelog&.release!(release.with_version(@version.read)) + end + def select_version!(version) raise ArgumentError, 'version object must implement set! when selecting a release version' unless @version.respond_to?(:set!) @version.set!(version) end - def prepare_publication(release) - @git.commit!(release) - @package.build!(release) - @git.tag!(release) + def prepare_publication(ctx) + run_stage(:commit_release, ctx) { @git.commit!(ctx.release) } + run_stage(:build_package, ctx) { @package.build!(ctx.release) } + run_stage(:tag_release, ctx) { @git.tag!(ctx.release) } end - def publish(release) - prepare_publication(release) - @destination.publish!(release, package: @package) - @git.push!(release) - @hosting&.publish!(release, package: @package) + def publish(ctx) + prepare_publication(ctx) + run_stage(:publish_registry, ctx) { @destination.publish!(ctx.release, package: @package) } + run_stage(:push_release, ctx) { @git.push!(ctx.release) } + run_stage(:publish_hosted_release, ctx) { @hosting.publish!(ctx.release, package: @package) } if @hosting end def prepare_next(ctx) - next_release = stage_next!(ctx.release) - @git.commit_next!(next_release) - @git.push! - ctx.record_next_version!(next_release.next_version) + next_ctx = stage_next!(ctx) + run_stage(:commit_next, next_ctx) { @git.commit_next!(next_ctx.release) } + run_stage(:push_next, next_ctx) { @git.push! } + ctx.record_next_version!(next_ctx.release.next_version) end - def stage_next!(release) - next_release = nil + def stage_next!(ctx) + next_ctx = ctx @git.stage_changes! do - next_release = release.with_next_version(@version.increment!(from: release.version)) - @changelog&.prepare_next!(next_release) + run_stage(:prepare_next, ctx, after_context: -> { next_ctx }) do + next_ctx = ctx.with(release: ctx.release.with_next_version(@version.increment!(from: ctx.release.version))) + @changelog&.prepare_next!(next_ctx.release) + end end - next_release + next_ctx end end end diff --git a/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb b/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb index 3353479..ccbcd29 100644 --- a/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb +++ b/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb @@ -20,14 +20,14 @@ def initialize(semantic_release: SemanticRelease.new, changelog: Changelog::Gene private def install_callbacks(tasks) - tasks.validate { |ctx| validate_release(ctx) } - tasks.prepare { |ctx| prepare_semantic_release(ctx) } + tasks.validate { |ctx| validate_with_hooks(ctx) } + tasks.prepare { |ctx| prepare_release(ctx) } install_result_callbacks(tasks) tasks.failure(&@failure) if @failure end def install_result_callbacks(tasks) - tasks.publish { |ctx| with_decision(ctx) { publish(ctx.release) } } + tasks.publish { |ctx| with_decision(ctx) { publish(ctx) } } tasks.bump { |ctx| with_decision(ctx) { prepare_next(ctx) if @bump } } tasks.success { |ctx| with_decision(ctx) { @success&.call(ctx) } } end @@ -52,12 +52,14 @@ def checked_decision(ctx) ctx.analysis end - def prepare_semantic_release(ctx) + def prepare_release(ctx) checked_decision(ctx) - @git.stage_changes! do - select_version!(ctx.release.version) - @changelog&.release!(ctx.release) - end + super + end + + def prepare_release_files(release) + select_version!(release.version) + @changelog&.release!(release) end end end diff --git a/spec/fixtures/portable_consumer.rb b/spec/fixtures/portable_consumer.rb index 229c857..38a27b6 100644 --- a/spec/fixtures/portable_consumer.rb +++ b/spec/fixtures/portable_consumer.rb @@ -1,148 +1,11 @@ # frozen_string_literal: true -require 'ruby-gem-release/workflow/ruby_gem' -require 'ruby-gem-release/workflow/semantic_ruby_gem' -require 'ruby-gem-release/changelog' -require 'digest' +require_relative 'portable_consumer_support' -raise 'Unexpected Actions environment' if ENV.keys.any? { |name| name.start_with?('GITHUB_', 'RUNNER_', 'ACTIONS_', 'GH_') } - -loaded = $LOADED_FEATURES.find { |path| path.end_with?('/ruby-gem-release/tasks.rb') } -expected = File.join(ENV.fetch('RELEASE_TEST_LIB'), 'ruby-gem-release/tasks.rb') -raise 'Consumer loaded the wrong release library' unless loaded && File.realpath(loaded) == File.realpath(expected) - -module PortableConsumer - def self.record(event, **attributes) - File.open(File.join(ENV.fetch('RELEASE_DATA_DIR'), 'events.jsonl'), 'a') do |file| - file.puts JSON.generate({ event: event, pid: Process.pid }.merge(attributes)) - end - end - - def self.context(ctx) - execution = { step: ctx.step.id, context_class: ctx.class.name, steps: ctx.steps, run: ctx.run, provider: ctx.workflow.provider.class.name } - execution.merge(release_context(ctx)) - end - - def self.release_context(ctx) - { - version: ctx.release&.version, gemspec_version: ctx.release&.gemspec_version, next_version: ctx.release&.next_version, - analysis: ctx.analysis&.to_h - } - end - - def self.remote_tags - output, error, status = Open3.capture3('git', '--git-dir', ENV.fetch('RELEASE_TEST_REMOTE'), 'tag') - raise error unless status.success? - - 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') - end - - def publish!(release, package:) - return if published?(release, package: package) - - path = package.artifact(release) - PortableConsumer.record('publish', path: path, sha256: Digest::SHA256.file(path).hexdigest, tags: PortableConsumer.remote_tags) - raise 'Registry rejected release' if ENV['CONSUMER_FAIL_PUBLICATION'] == 'true' - - File.write(File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'registry.json'), JSON.generate(sha: Digest::SHA256.file(path).hexdigest)) - end - - def published?(release, package:) - path = File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'registry.json') - return false unless File.file?(path) - raise 'Published checksum differs' unless JSON.parse(File.read(path)).fetch('sha') == Digest::SHA256.file(package.artifact(release)).hexdigest - - true - end - end - - class Hosting - def publish!(release, package:) - return if published?(release, package: package) - - path = package.artifact(release) - PortableConsumer.record('hosting', path: path, sha256: Digest::SHA256.file(path).hexdigest, tags: PortableConsumer.remote_tags, notes: release.notes) - File.write(File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'hosting.json'), JSON.generate(sha: Digest::SHA256.file(path).hexdigest)) - end - - def published?(release, package:) - path = File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'hosting.json') - return false unless File.file?(path) - raise 'Hosted checksum differs' unless JSON.parse(File.read(path)).fetch('sha') == Digest::SHA256.file(package.artifact(release)).hexdigest - - true - end - end - - class SemanticDecision < RubyGemRelease::SemanticRelease - def analyze(**arguments) - raise 'Semantic analysis was repeated' if ENV['CONSUMER_FORBID_ANALYSIS'] == 'true' - - super.tap { |decision| PortableConsumer.record('analysis', decision: decision.to_h) } - end - end - - module LocalCommands - def capture3(*command) - arguments = command.first.is_a?(Hash) ? command.drop(1) : command - ruby = arguments.first == Gem.ruby && (arguments[1] == '-rrubygems' || arguments[1, 3] == %w[-S gem build]) - raise 'Unexpected external command' unless arguments.first == 'git' || ruby - - super - end - end - - module NoNetwork - def start(*) - raise 'Unexpected network request' - end - end -end - -Open3.singleton_class.prepend(PortableConsumer::LocalCommands) -Net::HTTP.singleton_class.prepend(PortableConsumer::NoNetwork) - -semantic = ENV['CONSUMER_SEMANTIC'] == 'true' -workflow = semantic ? RubyGemRelease::Workflow::SemanticRubyGem : RubyGemRelease::Workflow::RubyGem -options = if semantic - { semantic_release: PortableConsumer::SemanticDecision.new } - else - { validate: lambda { |ctx| - PortableConsumer.record('validate', **PortableConsumer.context(ctx)) - ctx.workflow.changelog.pending? - } } - end - -workflow.new( - version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), - changelog: semantic ? RubyGemRelease::Changelog::Generated.new : RubyGemRelease::Changelog::KeepAChangelog.new, - provider: PortableConsumer::Provider.new, - destination: PortableConsumer::Destination.new, - hosting: PortableConsumer::Hosting.new, - success: ->(ctx) { PortableConsumer.record('success', **PortableConsumer.context(ctx)) }, - failure: lambda { |failure| - PortableConsumer.record('failure', phase: failure.phase, error: failure.error&.message, **PortableConsumer.context(failure.context)) - }, - **options -).install_tasks +PortableConsumer.install(validate: lambda { |ctx| + PortableConsumer.record('validate', **PortableConsumer.context(ctx)) + ctx.workflow.changelog.pending? +}) desc 'Verify the prepared package' task :test do diff --git a/spec/fixtures/portable_consumer_hooks.rb b/spec/fixtures/portable_consumer_hooks.rb new file mode 100644 index 0000000..4f675a1 --- /dev/null +++ b/spec/fixtures/portable_consumer_hooks.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'portable_consumer_support' +require_relative 'release_notes' + +PortableConsumer.install do |workflow| + RubyGemRelease::Workflow::Hooks::STAGES.each do |stage| + %i[before after].each do |timing| + workflow.public_send(timing, stage) do |ctx| + PortableConsumer.record('hook', stage: ctx.stage, timing: ctx.hook, index: ctx.hook_index, **PortableConsumer.context(ctx)) + end + end + + desc 'Verify the prepared package' + task :test do + raise 'Unexpected prepared version' unless RubyGemRelease::Package::RubyGem.new.version == ENV.fetch('RELEASE_VERSION') + end + + PortableConsumer.record('invocation', task: ARGV.last) + end + 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 diff --git a/spec/fixtures/portable_consumer_support.rb b/spec/fixtures/portable_consumer_support.rb new file mode 100644 index 0000000..4418736 --- /dev/null +++ b/spec/fixtures/portable_consumer_support.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'ruby-gem-release/workflow/ruby_gem' +require 'ruby-gem-release/workflow/semantic_ruby_gem' +require 'ruby-gem-release/changelog' +require 'digest' + +raise 'Unexpected Actions environment' if ENV.keys.any? { |name| name.start_with?('GITHUB_', 'RUNNER_', 'ACTIONS_', 'GH_') } + +loaded = $LOADED_FEATURES.find { |path| path.end_with?('/ruby-gem-release/tasks.rb') } +expected = File.join(ENV.fetch('RELEASE_TEST_LIB'), 'ruby-gem-release/tasks.rb') +raise 'Consumer loaded the wrong release library' unless loaded && File.realpath(loaded) == File.realpath(expected) + +module PortableConsumer + def self.record(event, **attributes) + File.open(File.join(ENV.fetch('RELEASE_DATA_DIR'), 'events.jsonl'), 'a') do |file| + file.puts JSON.generate({ event: event, pid: Process.pid }.merge(attributes)) + end + end + + def self.context(ctx) + execution = { step: ctx.step.id, context_class: ctx.class.name, steps: ctx.steps, run: ctx.run, provider: ctx.workflow.provider.class.name } + execution.merge(release_context(ctx)) + end + + def self.release_context(ctx) + { + version: ctx.release&.version, gemspec_version: ctx.release&.gemspec_version, next_version: ctx.release&.next_version, + analysis: ctx.analysis&.to_h + } + end + + def self.remote_tags + output, error, status = Open3.capture3('git', '--git-dir', ENV.fetch('RELEASE_TEST_REMOTE'), 'tag') + raise error unless status.success? + + 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') + end + + def publish!(release, package:) + return if published?(release, package: package) + + path = package.artifact(release) + PortableConsumer.record('publish', path: path, sha256: Digest::SHA256.file(path).hexdigest, tags: PortableConsumer.remote_tags) + raise 'Registry rejected release' if ENV['CONSUMER_FAIL_PUBLICATION'] == 'true' + + File.write(File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'registry.json'), JSON.generate(sha: Digest::SHA256.file(path).hexdigest)) + end + + def published?(release, package:) + path = File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'registry.json') + return false unless File.file?(path) + raise 'Published checksum differs' unless JSON.parse(File.read(path)).fetch('sha') == Digest::SHA256.file(package.artifact(release)).hexdigest + + true + end + end + + class Hosting + def publish!(release, package:) + return if published?(release, package: package) + + path = package.artifact(release) + PortableConsumer.record('hosting', path: path, sha256: Digest::SHA256.file(path).hexdigest, tags: PortableConsumer.remote_tags, notes: release.notes) + File.write(File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'hosting.json'), JSON.generate(sha: Digest::SHA256.file(path).hexdigest)) + end + + def published?(release, package:) + path = File.join(ENV.fetch('RELEASE_TEST_REMOTE'), 'hosting.json') + return false unless File.file?(path) + raise 'Hosted checksum differs' unless JSON.parse(File.read(path)).fetch('sha') == Digest::SHA256.file(package.artifact(release)).hexdigest + + true + end + end + + class SemanticDecision < RubyGemRelease::SemanticRelease + def analyze(**arguments) + raise 'Semantic analysis was repeated' if ENV['CONSUMER_FORBID_ANALYSIS'] == 'true' + + super.tap { |decision| PortableConsumer.record('analysis', decision: decision.to_h) } + end + end + + module LocalCommands + def capture3(*command) + arguments = command.first.is_a?(Hash) ? command.drop(1) : command + ruby = arguments.first == Gem.ruby && (arguments[1] == '-rrubygems' || arguments[1, 3] == %w[-S gem build]) + raise 'Unexpected external command' unless arguments.first == 'git' || ruby + + super + end + end + + module NoNetwork + def start(*) + raise 'Unexpected network request' + end + end + + def self.install(validate: nil) + configure_network + workflow = build_workflow(validate) + yield workflow if block_given? + workflow.install_tasks + workflow + end + + def self.configure_network + Open3.singleton_class.prepend(LocalCommands) + Net::HTTP.singleton_class.prepend(NoNetwork) + end + + def self.build_workflow(validate) + return semantic_workflow if ENV['CONSUMER_SEMANTIC'] == 'true' + + standard_workflow(validate) + end + + def self.semantic_workflow + RubyGemRelease::Workflow::SemanticRubyGem.new( + **workflow_options(RubyGemRelease::Changelog::Generated.new), + semantic_release: SemanticDecision.new + ) + end + + def self.standard_workflow(validate) + RubyGemRelease::Workflow::RubyGem.new( + **workflow_options(RubyGemRelease::Changelog::KeepAChangelog.new), + validate: validate + ) + end + + def self.workflow_options(changelog) + { + version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), + changelog: changelog, + provider: Provider.new, + destination: Destination.new, + hosting: Hosting.new, + **callback_options + } + end + + def self.callback_options + { + success: ->(ctx) { record('success', **context(ctx)) }, + failure: ->(failure) { record_failure(failure) } + } + end + + def self.record_failure(failure) + record('failure', phase: failure.phase, error: failure.error&.message, + stage: failure.error&.stage, hook: failure.error&.hook, hook_index: failure.error&.hook_index, + **context(failure.context)) + end +end diff --git a/spec/integration/workflow_portable_release_spec.rb b/spec/integration/workflow_portable_release_spec.rb index 8e6892c..8ff9508 100644 --- a/spec/integration/workflow_portable_release_spec.rb +++ b/spec/integration/workflow_portable_release_spec.rb @@ -8,6 +8,7 @@ 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(:environment) do ENV.to_h.slice('PATH', 'HOME', 'TMPDIR', 'LANG').merge( @@ -44,7 +45,8 @@ def checkout end RUBY File.write(File.join(checkout, 'CHANGELOG.md'), "# Changelog\n\n## [Unreleased]\n\n- Change.\n") - FileUtils.cp('spec/fixtures/portable_consumer.rb', File.join(checkout, 'Rakefile')) + 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', '.') @@ -154,6 +156,53 @@ def events expect(run_git('--git-dir', remote, 'show', 'master:lib/example/version.rb')).to include("VERSION = '0.2.1'") end + context 'with lifecycle hooks and the built-in KeepAChangelog workflow' do + let(:consumer_rakefile) { 'spec/fixtures/portable_consumer_hooks.rb' } + + before do + FileUtils.cp('examples/lifecycle_hooks/release_notes.rb', File.join(checkout, 'release_notes.rb')) + File.write(File.join(checkout, 'README.md'), "# Example Project\n\n## Stable Release\n\nDevelopment documentation.\n") + File.write(File.join(checkout, 'CHANGELOG.md'), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n* Change.\n* Your contribution here.\n\n### Fixed\n\n* Your contribution here.\n") + path = File.join(checkout, 'example.gemspec') + File.write(path, File.read(path).sub("['lib/example/version.rb']", "['lib/example/version.rb', 'README.md', 'CHANGELOG.md']")) + run_git('-C', checkout, 'add', '.') + run_git('-C', checkout, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.com', 'commit', '--quiet', '-m', 'Add project release hooks') + run_git('-C', checkout, 'push', '--quiet', 'origin', 'master') + end + + it 'places independent project edits in the release archive and the correct commits across fresh processes' do + prepare_release + run_task('release:publish') + run_task('release:bump') + run_task('release:success') + stable = run_git('--git-dir', remote, 'show', 'v0.2.0:README.md') + development = run_git('--git-dir', remote, 'show', 'master:README.md') + released_notes = run_git('--git-dir', remote, 'show', 'v0.2.0:CHANGELOG.md') + next_notes = run_git('--git-dir', remote, 'show', 'master:CHANGELOG.md') + expect(stable).to include('stable release of the project, 0.2.0') + expect(stable).not_to include('next release') + expect(development).to include('next release of the project, which should be 0.2.1', 'current stable release is 0.2.0') + expect(released_notes).to include('## [0.2.0] -', '* Change.') + expect(released_notes).not_to include('Your contribution here.', '### Fixed', '[Unreleased]') + expect(next_notes).to include('## [Unreleased]', '### Added', '### Fixed', 'Your contribution here.', released_notes.sub("# Changelog\n\n", '')) + Dir.mktmpdir do |unpacked| + Gem::Package.new(artifact('release.gem')).extract_files(unpacked) + expect(File.read(File.join(unpacked, 'README.md')).strip).to eq(stable) + expect(File.read(File.join(unpacked, 'CHANGELOG.md')).strip).to eq(released_notes) + end + hooks = events.select { |event| event['event'] == 'hook' } + expect(hooks.select { |event| event['timing'] == 'before' }.map { |event| event.fetch('stage') }).to eq( + %w[capture validate prepare_release resolve_version checkpoint commit_release build_package tag_release + authenticate commit_release build_package tag_release publish_registry push_release publish_hosted_release prepare_next commit_next push_next success] + ) + expect(hooks.count { |event| event['stage'] == 'build_package' && event['timing'] == 'before' }).to eq(2) + expect(hooks.find { |event| event['stage'] == 'capture' && event['timing'] == 'before' }).to include('version' => nil) + expect(hooks.find { |event| event['stage'] == 'capture' && event['timing'] == 'after' }).to include('version' => '0.2.0', 'gemspec_version' => '0.1.0') + expect(hooks.find { |event| event['stage'] == 'prepare_next' && event['timing'] == 'after' }).to include('next_version' => '0.2.1') + expect(run_git('-C', checkout, 'status', '--porcelain')).to eq('') + end + end + context 'with semantic releases' do let(:environment) { super().merge('CONSUMER_SEMANTIC' => 'true', 'INPUT_VERSION' => '') } diff --git a/spec/ruby_gem_release/tasks_callbacks_spec.rb b/spec/ruby_gem_release/tasks_callbacks_spec.rb index 3b58aa4..958f7d3 100644 --- a/spec/ruby_gem_release/tasks_callbacks_spec.rb +++ b/spec/ruby_gem_release/tasks_callbacks_spec.rb @@ -23,6 +23,13 @@ expect(Rake::Task.task_defined?('release:version:read')).to be(false) end + it 'does not require release metadata for absent result callbacks' do + %w[RELEASE_VERSION GEMSPEC_VERSION RELEASE_DATE].each { |key| ENV.delete(key) } + described_class.install + expect { Rake::Task['release:success'].invoke }.not_to raise_error + expect { Rake::Task['release:failure'].invoke }.not_to raise_error + end + it 'validates the selected release context without producing a decision output' do Dir.mktmpdir do |directory| ENV['GITHUB_ACTIONS'] = 'true' diff --git a/spec/ruby_gem_release/workflow/ruby_gem_spec.rb b/spec/ruby_gem_release/workflow/ruby_gem_spec.rb index 7105911..4c3af95 100644 --- a/spec/ruby_gem_release/workflow/ruby_gem_spec.rb +++ b/spec/ruby_gem_release/workflow/ruby_gem_spec.rb @@ -277,4 +277,178 @@ def run_callbacks expect { Rake::Task['release:bump'].invoke }.to raise_error('changelog failed') expect(events).to eq([:increment]) end + + it 'runs additive preparation hooks in order inside trusted staging without replacing the changelog' do + trusted = false + allow(objects.fetch(:git)).to receive(:stage_changes!) do |&block| + trusted = true + block.call + trusted = false + end + workflow = described_class.new(version: version, changelog: objects.fetch(:changelog)) + %i[before after].each do |timing| + 2.times do |index| + workflow.public_send(timing, :prepare_release) do |ctx| + expect(trusted).to be(true) + expect(ctx).to have_attributes(workflow: workflow, stage: :prepare_release, hook: timing, hook_index: index + 1) + events << [timing, index] + end + end + end + workflow.install_tasks + Rake::Task['release:prepare'].invoke + expect(events).to eq([[:before, 0], [:before, 1], :read, %i[changelog release!], [:after, 0], [:after, 1]]) + end + + it 'retains built-in validation and suppresses after hooks when it fails' do + workflow = described_class.new(version: version, changelog: objects.fetch(:changelog)) + workflow.before(:validate) { events << :before_validate } + workflow.after(:validate) { events << :after_validate } + allow(objects.fetch(:changelog)).to receive(:pending?).and_return(false) + workflow.install_tasks + expect { Rake::Task['release:validate'].invoke }.to raise_error(ArgumentError, /Nothing to release/) + expect(events).to eq([:before_validate]) + end + + it 'does not run validation after hooks when a replacement validator returns false' do + workflow = described_class.new(version: version, changelog: nil, validate: ->(_) { false }) + workflow.after(:validate) { events << :after_validate } + workflow.install_tasks + expect { Rake::Task['release:validate'].invoke }.to raise_error(ArgumentError, /callback returned false/) + expect(events).to be_empty + end + + it 'supplies the calculated next version before the development commit and distinguishes both pushes' do + workflow = described_class.new(version: version, changelog: objects.fetch(:changelog)) + %i[prepare_next push_release push_next].each do |stage| + workflow.after(stage) { |ctx| events << [stage, ctx.release.next_version] } + end + workflow.install_tasks + run_callbacks + expect(events.last(8)).to eq([ + [:push_release, nil], %i[hosting publish!], :increment, %i[changelog prepare_next!], + [:prepare_next, '2.0.1'], %i[git commit_next!], %i[git push!], [:push_next, '2.0.1'] + ]) + end + + it 'preserves result callbacks between ordered hooks and exposes the original failure' do + workflow = described_class.new(version: version, changelog: nil, + success: ->(_) { events << :success_callback }, failure: ->(_) { events << :failure_callback }) + %i[success failure].each do |stage| + workflow.before(stage) do |ctx| + expect(ctx.failure).to be_a(RubyGemRelease::ReleaseFailure) if stage == :failure + events << :"before_#{stage}" + end + workflow.after(stage) { events << :"after_#{stage}" } + end + workflow.install_tasks + Rake::Task['release:success'].invoke + Rake::Task['release:failure'].invoke + expect(events).to eq(%i[before_success success_callback after_success before_failure failure_callback after_failure]) + end + + it 'captures the exact failing stage and hook without changing the exception class' do + Dir.mktmpdir do |directory| + ENV.update('RELEASE_DATA_DIR' => directory, 'RELEASE_FAILED_STEP_ID' => 'release:prepare') + received = nil + workflow = described_class.new(version: version, changelog: nil, failure: ->(failure) { received = failure }) + workflow.before(:prepare_release) { events << :first } + workflow.before(:prepare_release) { raise ArgumentError, 'project notes invalid' } + workflow.after(:prepare_release) { events << :after } + workflow.install_tasks + expect { Rake::Task['release:prepare'].invoke }.to raise_error(ArgumentError, 'project notes invalid') + Rake::Task['release:failure'].invoke + expect(received.error).to have_attributes(original_class: 'ArgumentError', stage: :prepare_release, hook: :before, hook_index: 2) + expect(events).to eq([:first]) + end + end + + it 'reports built-in operation failures with their lifecycle stage' do + Dir.mktmpdir do |directory| + ENV.update('RELEASE_DATA_DIR' => directory, 'RELEASE_FAILED_STEP_ID' => 'release:publish') + received = nil + allow(objects.fetch(:package)).to receive(:build!).and_raise('build failed') + install(failure: ->(failure) { received = failure }) + expect { Rake::Task['release:publish'].invoke }.to raise_error('build failed') + Rake::Task['release:failure'].invoke + expect(received.error).to have_attributes(stage: :build_package, hook: nil, hook_index: nil) + end + end + + it 'rejects unknown stages and hooks without a block at registration' do + workflow = described_class.new(version: version, changelog: nil) + expect { workflow.before(:push) { nil } }.to raise_error(ArgumentError, /unknown lifecycle stage/) + expect { workflow.after(:validate) }.to raise_error(ArgumentError, /requires a block/) + end + + it 'runs checkpoint and publication preparation hooks in both tasks' do + workflow = described_class.new(version: version, changelog: nil, hosting: nil) + %i[checkpoint commit_release build_package tag_release publish_registry publish_hosted_release].each do |stage| + workflow.before(stage) { events << stage } + end + workflow.install_tasks + Rake::Task['release:workflow:checkpoint'].invoke + 2.times do + Rake::Task['release:publish'].reenable + Rake::Task['release:publish'].invoke + end + expect(events.count(:checkpoint)).to eq(1) + expect(events.count(:build_package)).to eq(3) + expect(events.count(:commit_release)).to eq(3) + expect(events.count(:tag_release)).to eq(3) + expect(events.count(:publish_registry)).to eq(2) + expect(events).not_to include(:publish_hosted_release) + end + + it 'skips authentication hooks when the destination already verifies the exact publication' do + allow(objects.fetch(:package)).to receive(:built?).and_return(true) + allow(objects.fetch(:destination)).to receive(:published?).and_return(true) + workflow = described_class.new(version: version, changelog: nil) + workflow.before(:authenticate) { events << :authenticate_hook } + workflow.install_tasks + Rake::Task['release:publish'].invoke + expect(events).not_to include(:authenticate_hook, %i[destination authenticate!]) + end + + it 'does not run subsequent hooks after an after hook fails' do + workflow = described_class.new(version: version, changelog: nil) + workflow.after(:prepare_release) { raise 'readme update failed' } + workflow.after(:prepare_release) { events << :unreachable } + workflow.install_tasks + expect { Rake::Task['release:prepare'].invoke }.to raise_error('readme update failed') + expect(events).to be_empty + end + + it 'does not overwrite the original captured error when a failure hook fails' do + Dir.mktmpdir do |directory| + ENV.update('RELEASE_DATA_DIR' => directory, 'RELEASE_FAILED_STEP_ID' => 'release:validate') + workflow = described_class.new(version: version, changelog: nil, validate: ->(_) { raise 'original validation error' }) + workflow.before(:failure) { raise 'notification failed' } + workflow.after(:failure) { events << :unreachable } + workflow.install_tasks + expect { Rake::Task['release:validate'].invoke }.to raise_error('original validation error') + expect { Rake::Task['release:failure'].invoke }.to raise_error('notification failed') + original = RubyGemRelease::Workflow::Failure.error_for('release:validate', provider: workflow.provider) + expect(original).to have_attributes(message: 'original validation error', stage: :validate) + expect(events).to be_empty + end + end + + it 'retains concurrency checks before registry publication' do + allow(objects.fetch(:git)).to receive(:tag!).and_raise('Default branch advanced; start a new release') + install + expect { Rake::Task['release:publish'].invoke }.to raise_error(/Default branch advanced/) + expect(objects.fetch(:destination)).not_to have_received(:publish!) + expect(objects.fetch(:package)).to have_received(:build!) + end + + it 'runs result hooks without requiring replacement result callbacks' do + workflow = described_class.new(version: version, changelog: nil) + workflow.after(:success) { events << :success_hook } + workflow.after(:failure) { events << :failure_hook } + workflow.install_tasks + Rake::Task['release:success'].invoke + Rake::Task['release:failure'].invoke + expect(events).to eq(%i[success_hook failure_hook]) + end end diff --git a/spec/ruby_gem_release/workflow/semantic_ruby_gem_spec.rb b/spec/ruby_gem_release/workflow/semantic_ruby_gem_spec.rb index 4242d12..230edc2 100644 --- a/spec/ruby_gem_release/workflow/semantic_ruby_gem_spec.rb +++ b/spec/ruby_gem_release/workflow/semantic_ruby_gem_spec.rb @@ -32,10 +32,14 @@ Rake.application = original end - before { allow(components.fetch(:git)).to receive(:stage_changes!).and_yield } + before do + allow(components.fetch(:git)).to receive(:stage_changes!).and_yield + end def install(**options) - described_class.new(**components, semantic_release: semantic, provider: provider, hosting: nil, **options).install_tasks + workflow = described_class.new(**components, semantic_release: semantic, provider: provider, hosting: nil, **options) + yield workflow if block_given? + workflow.install_tasks end def invoke(*tasks) @@ -58,11 +62,15 @@ def invoke(*tasks) it 'optionally bumps and records the next development version without replacing analysis' do received = nil - install(bump: true, success: ->(ctx) { received = ctx }) + prepared = nil + install(bump: true, success: ->(ctx) { received = ctx }) do |workflow| + workflow.after(:prepare_next) { |ctx| prepared = ctx } + end invoke('validate', 'prepare', 'publish', 'bump', 'success') expect(components.fetch(:version)).to have_received(:increment!) expect(received.release.next_version).to eq('1.3.1') expect(received.analysis.to_h).to eq(decision.to_h) + expect(prepared).to have_attributes(analysis: decision, release: have_attributes(next_version: '1.3.1')) end it 'rejects a no-release decision without consulting handwritten notes or invoking downstream operations' do @@ -120,4 +128,17 @@ def invoke(*tasks) expect(components.fetch(:changelog)).not_to have_received(:release!) expect { described_class.new(version: components.fetch(:version), bump: :patch) }.to raise_error(ArgumentError, /bump/) end + + it 'exposes the selected decision after validation and shares preparation hooks without enabling development bumps' do + received = [] + workflow = described_class.new(**components, semantic_release: semantic, provider: provider, hosting: nil) + %i[validate prepare_release prepare_next commit_next push_next].each do |stage| + workflow.after(stage) { |ctx| received << ctx } + end + workflow.install_tasks + invoke('validate', 'prepare', 'publish', 'bump') + expect(received.map(&:stage)).to eq(%i[validate prepare_release]) + expect(received).to all(have_attributes(analysis: decision, release: have_attributes(version: '1.3.0', notes: 'Generated notes.'))) + expect(semantic).to have_received(:analyze).once + end end