ruby-gem-release 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5bb3465b2672800174dc8160f4f3dea172fab317cd9c6698c5e9555838830d9
4
- data.tar.gz: 2c5c68af968df8d0a2b834efa4a3dc15c197a0567848588e9c819a2e7218d0db
3
+ metadata.gz: 6147db94530ebf0d53d63e1988df089cf1d9442721f3d5a45384731e308d5c91
4
+ data.tar.gz: c5ebef85abb8868a7cfa8f0316a5be602b359443140f399e981b2060f4b59404
5
5
  SHA512:
6
- metadata.gz: 75c793e1f932297cee9148c38a8fa85d642861f8e5bf884ec1911842fed7fa8ea19067f61f5a7c98e08eb1efabf44902557261cb72383c8f09723393611e0355
7
- data.tar.gz: c28d2ccd7ef2588003a20c23c8275bbf3d077c9ecfb2a8cd113fbcc4c45e568c714cdd11a419a492cd291e53dfd08486686ef87612e4ad50cc3051c5773bfdd7
6
+ metadata.gz: 11ef16efd08c2a7c87c3c3f758b391b20c258bc3bc4b14e1a7a77299a27a971a8746c636aea9035e0d3fe87c87594c9c06829c64d80ff4ab877b28e09e2f1cb5
7
+ data.tar.gz: c17e11145abae06232754512046f5212c35309da77feef552e11d730e3cc4fb5bc70133c5f44b4df6334f5d586f8f1d8f54d13f3e9962d6243a29e28238304f6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 (2026/11/10)
4
+
5
+ - [#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).
6
+
3
7
  ## 0.5.0 (2026/11/10)
4
8
 
5
9
  - [#28](https://github.com/dblock/ruby-gem-release/pull/28): Add success callbacks and structured failure callbacks with captured Ruby exceptions to both Actions interfaces - [@dblock](https://github.com/dblock).
data/README.md CHANGED
@@ -53,7 +53,7 @@ permissions:
53
53
 
54
54
  jobs:
55
55
  release:
56
- uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.4.0
56
+ uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.5.0
57
57
  with:
58
58
  ruby-version: '3.3'
59
59
  version: ${{ inputs.version }}
@@ -97,6 +97,7 @@ For a real-world example of adding release automation to an existing gem, see [r
97
97
  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.
98
98
 
99
99
  - **Project conventions:** Choose [version objects](docs/usage.md#version-objects) and [changelog formats](docs/usage.md#changelog-objects), including custom implementations.
100
+ - **Stage customization:** Add ordered [lifecycle hooks](docs/workflow/hooks.md) for validation, trusted preparation edits, publication observers, and notifications without replacing the pipeline.
100
101
  - **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.
101
102
  - **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.
102
103
  - **Packages and registries:** Override [package construction](docs/package/ruby_gem.md) or supply a [registry destination](docs/destination.md) independently of hosted releases.
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'execution_context/step'
4
+ require_relative 'release'
4
5
 
5
6
  module RubyGemRelease
6
7
  class ExecutionContext
7
- attr_reader :release, :step, :steps, :workflow, :run, :analysis
8
+ attr_reader :release, :step, :steps, :workflow, :run, :analysis, :stage, :hook, :hook_index, :failure
8
9
 
9
10
  def self.step_id(task)
10
11
  task
@@ -28,5 +29,23 @@ module RubyGemRelease
28
29
  def record_analysis!(decision)
29
30
  @state.record_analysis!(decision)
30
31
  end
32
+
33
+ def with(release: @release, analysis: @analysis, stage: nil, hook: nil, hook_index: nil, failure: @failure)
34
+ copy = dup
35
+ { release: release, analysis: analysis, stage: stage, hook: hook, hook_index: hook_index, failure: failure }.each do |name, value|
36
+ copy.instance_variable_set("@#{name}", value)
37
+ end
38
+ copy.freeze
39
+ end
40
+
41
+ def refresh
42
+ snapshot = @state.snapshot
43
+ analysis = snapshot.fetch(:analysis)
44
+ release = if @release
45
+ Release.new(version: analysis&.version || @release.version, gemspec_version: @release.gemspec_version, date: @release.date,
46
+ next_version: snapshot.fetch(:next_version), notes: analysis&.notes || @release.notes)
47
+ end
48
+ with(release: release, analysis: analysis)
49
+ end
31
50
  end
32
51
  end
@@ -3,24 +3,30 @@
3
3
  module RubyGemRelease
4
4
  class ReleaseFailure
5
5
  class CapturedError < StandardError
6
- attr_reader :original_class
6
+ attr_reader :original_class, :stage, :hook, :hook_index
7
7
 
8
- def self.capture(error)
9
- new(original_class: error.class.name || error.class.to_s, message: error.message, backtrace: error.backtrace)
8
+ def self.capture(error, **details)
9
+ new(original_class: error.class.name || error.class.to_s, message: error.message, backtrace: error.backtrace, **details)
10
10
  end
11
11
 
12
12
  def self.from_h(attributes)
13
- new(original_class: attributes.fetch('class'), message: attributes.fetch('message'), backtrace: attributes.fetch('backtrace'))
13
+ new(original_class: attributes.fetch('class'), message: attributes.fetch('message'), backtrace: attributes.fetch('backtrace'),
14
+ stage: attributes['stage'], hook: attributes['hook'], hook_index: attributes['hook_index'])
14
15
  end
15
16
 
16
- def initialize(original_class:, message:, backtrace:)
17
+ def initialize(original_class:, message:, backtrace:, stage: nil, hook: nil, hook_index: nil)
17
18
  @original_class = original_class.freeze
19
+ @stage = stage&.to_sym
20
+ @hook = hook&.to_sym
21
+ @hook_index = hook_index
18
22
  super(message)
19
23
  set_backtrace(backtrace)
20
24
  end
21
25
 
22
26
  def to_h
23
- { 'class' => original_class, 'message' => message, 'backtrace' => backtrace }
27
+ { 'class' => original_class, 'message' => message, 'backtrace' => backtrace }.merge(
28
+ { 'stage' => stage&.to_s, 'hook' => hook&.to_s, 'hook_index' => hook_index }.compact
29
+ )
24
30
  end
25
31
  end
26
32
 
@@ -108,11 +108,28 @@ module RubyGemRelease
108
108
 
109
109
  def install_workflow_context_tasks
110
110
  install_checkpoint_task
111
+ install_capture_task
112
+ install_resolve_task
111
113
  task(:configure) { with_failure_capture('release:workflow:configure') { Workflow::Environment.new(provider: @provider).configure! } }
112
- task(:capture) { with_failure_capture('release:workflow:capture') { Workflow::Environment.new(provider: @provider).capture!(package: @package) } }
114
+ end
115
+
116
+ def install_capture_task
117
+ task(:capture) do
118
+ with_failure_capture('release:workflow:capture') do
119
+ capture_context = @provider.context(release: nil, task: 'release:workflow:capture', state: Workflow::State.new, workflow: @workflow)
120
+ lifecycle(:capture, capture_context, after_context: -> { context('release:workflow:capture', preparing: true) }) do
121
+ Workflow::Environment.new(provider: @provider).capture!(package: @package)
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ def install_resolve_task
113
128
  task(:resolve) do
114
129
  with_failure_capture('release:workflow:resolve') do
115
- Workflow::Environment.new(provider: @provider).resolve!(package: @package, destination: @destination, analysis: state.analysis)
130
+ lifecycle(:resolve_version, context('release:workflow:resolve', preparing: true), after_context: -> { context('release:workflow:resolve') }) do
131
+ Workflow::Environment.new(provider: @provider).resolve!(package: @package, destination: @destination, analysis: state.analysis)
132
+ end
116
133
  end
117
134
  end
118
135
  end
@@ -134,20 +151,32 @@ module RubyGemRelease
134
151
  next unless @publish
135
152
 
136
153
  ctx = context('release:publish')
137
- @destination.authenticate!(provider: @provider) unless @workflow&.published?(ctx.release)
154
+ lifecycle(:authenticate, ctx) { @destination.authenticate!(provider: @provider) } unless @workflow&.published?(ctx.release)
138
155
  @publish.call(ctx)
139
156
  end
140
157
  end
141
158
  end
142
159
 
143
160
  def install_result_tasks
144
- desc 'Run the release success callback'
145
- task :success do
146
- with_failure_capture('release:success') { @success&.call(context('release:success')) }
147
- end
161
+ install_success_task
148
162
  desc 'Run the release failure callback'
149
163
  task :failure do
150
- @failure&.call(failure_context)
164
+ next unless @failure || lifecycle_hooks?(:failure)
165
+
166
+ failure = failure_context
167
+ lifecycle(:failure, failure.context.with(failure: failure)) { @failure&.call(failure) }
168
+ end
169
+ end
170
+
171
+ def install_success_task
172
+ desc 'Run the release success callback'
173
+ task :success do
174
+ next unless @success || lifecycle_hooks?(:success)
175
+
176
+ with_failure_capture('release:success') do
177
+ ctx = context('release:success')
178
+ lifecycle(:success, ctx) { @success&.call(ctx) }
179
+ end
151
180
  end
152
181
  end
153
182
 
@@ -245,10 +274,21 @@ module RubyGemRelease
245
274
  def with_failure_capture(task)
246
275
  yield
247
276
  rescue StandardError => e
248
- Workflow::Failure.capture!(@provider.step_id(task), e, provider: @provider)
277
+ details = @workflow.respond_to?(:failure_details) ? @workflow.failure_details(e) : {}
278
+ Workflow::Failure.capture!(@provider.step_id(task), e, provider: @provider, **details)
249
279
  raise
250
280
  end
251
281
 
282
+ def lifecycle(stage, ctx, **, &)
283
+ return yield unless @workflow.respond_to?(:run_stage)
284
+
285
+ @workflow.run_stage(stage, ctx, **, &)
286
+ end
287
+
288
+ def lifecycle_hooks?(stage)
289
+ @workflow.respond_to?(:hooks?) && @workflow.hooks?(stage)
290
+ end
291
+
252
292
  def validate_context_versions(*versions)
253
293
  pattern = Version::RubyConstant::PATTERN
254
294
  versions.compact.each do |value|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyGemRelease
4
- VERSION = '0.5.0'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -14,10 +14,10 @@ module RubyGemRelease
14
14
  provider.export('RELEASE_FAILED_STEP_ID' => phase)
15
15
  end
16
16
 
17
- def capture!(phase, error, provider: ExecutionProvider.current)
17
+ def capture!(phase, error, provider: ExecutionProvider.current, **details)
18
18
  return unless provider.directory
19
19
 
20
- captured_error = ReleaseFailure::CapturedError.capture(error)
20
+ captured_error = ReleaseFailure::CapturedError.capture(error, **details)
21
21
  File.open(failure_file(provider), 'w', 0o600) do |file|
22
22
  file.write(JSON.generate('phase' => phase, 'error' => captured_error.to_h))
23
23
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ module Workflow
5
+ class Hooks
6
+ STAGES = %i[capture resolve_version validate prepare_release authenticate checkpoint commit_release build_package tag_release
7
+ publish_registry push_release publish_hosted_release prepare_next commit_next push_next success failure].freeze
8
+
9
+ def initialize
10
+ @hooks = { before: {}, after: {} }
11
+ end
12
+
13
+ def register(timing, stage, &block)
14
+ raise ArgumentError, "unknown lifecycle stage: #{stage.inspect}" unless STAGES.include?(stage)
15
+ raise ArgumentError, 'hook requires a block' unless block
16
+
17
+ (@hooks.fetch(timing)[stage] ||= []) << block
18
+ end
19
+
20
+ def registered?(stage)
21
+ @hooks.values.any? { |stages| !stages.fetch(stage, []).empty? }
22
+ end
23
+
24
+ def run(stage, context, after_context: nil)
25
+ call_hooks(:before, stage, context)
26
+ result = yield
27
+ call_hooks(:after, stage, after_context ? after_context.call : context) unless @hooks.fetch(:after).fetch(stage, []).empty?
28
+ result
29
+ rescue StandardError => e
30
+ record_failure(e, stage: stage)
31
+ raise
32
+ end
33
+
34
+ def failure_details(error)
35
+ @failure_error.equal?(error) ? @failure_details : {}
36
+ end
37
+
38
+ private
39
+
40
+ def call_hooks(timing, stage, context)
41
+ @hooks.fetch(timing).fetch(stage, []).each_with_index do |block, index|
42
+ block.call(context.with(stage: stage, hook: timing, hook_index: index + 1))
43
+ rescue StandardError => e
44
+ record_failure(e, stage: stage, hook: timing, hook_index: index + 1)
45
+ raise
46
+ end
47
+ end
48
+
49
+ def record_failure(error, **details)
50
+ return if @failure_error.equal?(error)
51
+
52
+ @failure_error = error
53
+ @failure_details = details
54
+ end
55
+ end
56
+ end
57
+ end
@@ -5,6 +5,7 @@ require_relative '../package/ruby_gem'
5
5
  require_relative '../destination/ruby_gems'
6
6
  require_relative '../source_control/git'
7
7
  require_relative '../hosting/github_release'
8
+ require_relative 'hooks'
8
9
 
9
10
  module RubyGemRelease
10
11
  module Workflow
@@ -28,6 +29,28 @@ module RubyGemRelease
28
29
  @failure = failure
29
30
  end
30
31
 
32
+ def before(stage, &)
33
+ hooks.register(:before, stage, &)
34
+ self
35
+ end
36
+
37
+ def after(stage, &)
38
+ hooks.register(:after, stage, &)
39
+ self
40
+ end
41
+
42
+ def run_stage(stage, context, **, &)
43
+ hooks.run(stage, context, **, &)
44
+ end
45
+
46
+ def failure_details(error)
47
+ hooks.failure_details(error)
48
+ end
49
+
50
+ def hooks?(stage)
51
+ hooks.registered?(stage)
52
+ end
53
+
31
54
  def install_tasks
32
55
  Tasks.install(workflow: self, package: @package, destination: @destination, provider: @provider) do |tasks|
33
56
  install_callbacks(tasks)
@@ -35,7 +58,7 @@ module RubyGemRelease
35
58
  end
36
59
 
37
60
  def checkpoint!(context)
38
- prepare_publication(context.release)
61
+ run_stage(:checkpoint, context) { prepare_publication(context) }
39
62
  end
40
63
 
41
64
  def published?(release)
@@ -44,15 +67,25 @@ module RubyGemRelease
44
67
 
45
68
  private
46
69
 
70
+ def hooks
71
+ @hooks ||= Hooks.new
72
+ end
73
+
47
74
  def install_callbacks(tasks)
48
- tasks.validate { |ctx| validate_release(ctx) }
49
- tasks.prepare { |ctx| prepare_release(ctx.release) }
50
- tasks.publish { |ctx| publish(ctx.release) }
75
+ tasks.validate { |ctx| validate_with_hooks(ctx) }
76
+ tasks.prepare { |ctx| prepare_release(ctx) }
77
+ tasks.publish { |ctx| publish(ctx) }
51
78
  tasks.bump { |ctx| prepare_next(ctx) }
52
79
  tasks.success(&@success) if @success
53
80
  tasks.failure(&@failure) if @failure
54
81
  end
55
82
 
83
+ def validate_with_hooks(ctx)
84
+ run_stage(:validate, ctx, after_context: -> { ctx.refresh }) do
85
+ raise ArgumentError, 'Release validation failed: callback returned false' if validate_release(ctx) == false
86
+ end
87
+ end
88
+
56
89
  def validate_release(ctx)
57
90
  return @validate.call(ctx) if @validate
58
91
 
@@ -61,46 +94,52 @@ module RubyGemRelease
61
94
  raise ArgumentError, 'Nothing to release: no pending changelog notes'
62
95
  end
63
96
 
64
- def prepare_release(release)
97
+ def prepare_release(ctx)
65
98
  @git.stage_changes! do
66
- select_version!(release.version) unless @provider.fetch('INPUT_VERSION', '').empty?
67
- @changelog&.release!(release.with_version(@version.read))
99
+ run_stage(:prepare_release, ctx) { prepare_release_files(ctx.release) }
68
100
  end
69
101
  end
70
102
 
103
+ def prepare_release_files(release)
104
+ select_version!(release.version) unless @provider.fetch('INPUT_VERSION', '').empty?
105
+ @changelog&.release!(release.with_version(@version.read))
106
+ end
107
+
71
108
  def select_version!(version)
72
109
  raise ArgumentError, 'version object must implement set! when selecting a release version' unless @version.respond_to?(:set!)
73
110
 
74
111
  @version.set!(version)
75
112
  end
76
113
 
77
- def prepare_publication(release)
78
- @git.commit!(release)
79
- @package.build!(release)
80
- @git.tag!(release)
114
+ def prepare_publication(ctx)
115
+ run_stage(:commit_release, ctx) { @git.commit!(ctx.release) }
116
+ run_stage(:build_package, ctx) { @package.build!(ctx.release) }
117
+ run_stage(:tag_release, ctx) { @git.tag!(ctx.release) }
81
118
  end
82
119
 
83
- def publish(release)
84
- prepare_publication(release)
85
- @destination.publish!(release, package: @package)
86
- @git.push!(release)
87
- @hosting&.publish!(release, package: @package)
120
+ def publish(ctx)
121
+ prepare_publication(ctx)
122
+ run_stage(:publish_registry, ctx) { @destination.publish!(ctx.release, package: @package) }
123
+ run_stage(:push_release, ctx) { @git.push!(ctx.release) }
124
+ run_stage(:publish_hosted_release, ctx) { @hosting.publish!(ctx.release, package: @package) } if @hosting
88
125
  end
89
126
 
90
127
  def prepare_next(ctx)
91
- next_release = stage_next!(ctx.release)
92
- @git.commit_next!(next_release)
93
- @git.push!
94
- ctx.record_next_version!(next_release.next_version)
128
+ next_ctx = stage_next!(ctx)
129
+ run_stage(:commit_next, next_ctx) { @git.commit_next!(next_ctx.release) }
130
+ run_stage(:push_next, next_ctx) { @git.push! }
131
+ ctx.record_next_version!(next_ctx.release.next_version)
95
132
  end
96
133
 
97
- def stage_next!(release)
98
- next_release = nil
134
+ def stage_next!(ctx)
135
+ next_ctx = ctx
99
136
  @git.stage_changes! do
100
- next_release = release.with_next_version(@version.increment!(from: release.version))
101
- @changelog&.prepare_next!(next_release)
137
+ run_stage(:prepare_next, ctx, after_context: -> { next_ctx }) do
138
+ next_ctx = ctx.with(release: ctx.release.with_next_version(@version.increment!(from: ctx.release.version)))
139
+ @changelog&.prepare_next!(next_ctx.release)
140
+ end
102
141
  end
103
- next_release
142
+ next_ctx
104
143
  end
105
144
  end
106
145
  end
@@ -20,14 +20,14 @@ module RubyGemRelease
20
20
  private
21
21
 
22
22
  def install_callbacks(tasks)
23
- tasks.validate { |ctx| validate_release(ctx) }
24
- tasks.prepare { |ctx| prepare_semantic_release(ctx) }
23
+ tasks.validate { |ctx| validate_with_hooks(ctx) }
24
+ tasks.prepare { |ctx| prepare_release(ctx) }
25
25
  install_result_callbacks(tasks)
26
26
  tasks.failure(&@failure) if @failure
27
27
  end
28
28
 
29
29
  def install_result_callbacks(tasks)
30
- tasks.publish { |ctx| with_decision(ctx) { publish(ctx.release) } }
30
+ tasks.publish { |ctx| with_decision(ctx) { publish(ctx) } }
31
31
  tasks.bump { |ctx| with_decision(ctx) { prepare_next(ctx) if @bump } }
32
32
  tasks.success { |ctx| with_decision(ctx) { @success&.call(ctx) } }
33
33
  end
@@ -52,12 +52,14 @@ module RubyGemRelease
52
52
  ctx.analysis
53
53
  end
54
54
 
55
- def prepare_semantic_release(ctx)
55
+ def prepare_release(ctx)
56
56
  checked_decision(ctx)
57
- @git.stage_changes! do
58
- select_version!(ctx.release.version)
59
- @changelog&.release!(ctx.release)
60
- end
57
+ super
58
+ end
59
+
60
+ def prepare_release_files(release)
61
+ select_version!(release.version)
62
+ @changelog&.release!(release)
61
63
  end
62
64
  end
63
65
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-gem-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Doubrovkine
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-10 00:00:00.000000000 Z
10
+ date: 2026-11-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: commonmarker
@@ -81,6 +81,7 @@ files:
81
81
  - lib/ruby-gem-release/version/ruby_constant.rb
82
82
  - lib/ruby-gem-release/workflow/environment.rb
83
83
  - lib/ruby-gem-release/workflow/failure.rb
84
+ - lib/ruby-gem-release/workflow/hooks.rb
84
85
  - lib/ruby-gem-release/workflow/ruby_gem.rb
85
86
  - lib/ruby-gem-release/workflow/ruby_gems_credentials.rb
86
87
  - lib/ruby-gem-release/workflow/semantic_ruby_gem.rb