Class: Kettle::Dev::ReleaseCLI

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/release_cli.rb

Instance Method Summary collapse

Constructor Details

#initialize(start_step: 1) ⇒ ReleaseCLI

Returns a new instance of ReleaseCLI.



33
34
35
36
37
38
# File 'lib/kettle/dev/release_cli.rb', line 33

def initialize(start_step: 1)
  @root = Kettle::Dev::CIHelpers.project_root
  @git = Kettle::Dev::GitAdapter.new
  @start_step = (start_step || 1).to_i
  @start_step = 1 if @start_step < 1
end

Instance Method Details

#runObject



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/kettle/dev/release_cli.rb', line 40

def run
  # 1. Ensure Bundler version āœ“
  ensure_bundler_2_7_plus!

  version = nil
  committed = nil
  trunk = nil
  feature = nil

  # 2. Version detection and sanity checks + prompt
  if @start_step <= 2
    version = detect_version
    puts "Detected version: #{version.inspect}"

    latest_overall = nil
    latest_for_series = nil
    begin
      gem_name = detect_gem_name
      latest_overall, latest_for_series = latest_released_versions(gem_name, version)
    rescue StandardError => e
      warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
    end

    if latest_overall
      msg = "Latest released: #{latest_overall}"
      if latest_for_series && latest_for_series != latest_overall
        msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
      elsif latest_for_series
        msg += " (matches current series)"
      end
      puts msg

      cur = Gem::Version.new(version)
      overall = Gem::Version.new(latest_overall)
      cur_series = cur.segments[0, 2]
      overall_series = overall.segments[0, 2]
      # Ensure latest_for_series actually matches our current series; ignore otherwise.
      if latest_for_series
        lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
        latest_for_series = nil unless lfs_series == cur_series
      end
      # Determine the sanity-check target correctly for the current series.
      # If RubyGems has a newer overall series than our current series, only compare
      # against the latest published in our current series. If that cannot be determined
      # (e.g., offline), skip the sanity check rather than treating the overall as target.
      target = if (cur_series <=> overall_series) == -1
        latest_for_series
      else
        latest_overall
      end
      # IMPORTANT: Never treat a higher different-series "latest_overall" as a downgrade target.
      # If our current series is behind overall and RubyGems does not report a latest_for_series,
      # then we cannot determine the correct target for this series and should skip the check.
      if (cur_series <=> overall_series) == -1 && target.nil?
        puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
      elsif target
        bump = Kettle::Dev::Versioning.classify_bump(target, version)
        case bump
        when :same
          series = cur_series.join(".")
          warn("version.rb (#{version}) matches the latest released version for series #{series} (#{target}).")
          abort("Aborting: version bump required. Bump PATCH/MINOR/MAJOR/EPIC.")
        when :downgrade
          series = cur_series.join(".")
          warn("version.rb (#{version}) is lower than the latest released version for series #{series} (#{target}).")
          abort("Aborting: version must be bumped above #{target}.")
        else
          label = {epic: "EPIC", major: "MAJOR", minor: "MINOR", patch: "PATCH"}[bump] || bump.to_s.upcase
          puts "Proposed bump type: #{label} (from #{target} -> #{version})"
        end
      else
        puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
      end
    else
      puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
    end

    puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
    print("> ")
    ans = Kettle::Dev::InputAdapter.gets&.strip
    abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")

    # Initial validation: Ensure README.md and LICENSE.txt have identical sets of copyright years; also ensure current year present when matched
    validate_copyright_years!

    # Ensure README KLOC badge reflects current CHANGELOG coverage denominator
    begin
      update_readme_kloc_badge!
    rescue StandardError => e
      warn("Failed to update KLOC badge in README: #{e.class}: #{e.message}")
    end

    # Update Rakefile.example header banner with current version and date
    begin
      update_rakefile_example_header!(version)
    rescue StandardError => e
      warn("Failed to update Rakefile.example header: #{e.class}: #{e.message}")
    end
  end

  # 3. bin/setup
  run_cmd!("bin/setup") if @start_step <= 3
  # 4. bin/rake
  run_cmd!("bin/rake") if @start_step <= 4

  # 5. appraisal:update (optional)
  if @start_step <= 5
    appraisals_path = File.join(@root, "Appraisals")
    if File.file?(appraisals_path)
      puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
      run_cmd!("bin/rake appraisal:update")
    else
      puts "No Appraisals file found; skipping appraisal:update"
    end
  end

  # 6. git user + commit release prep
  if @start_step <= 6
    ensure_git_user!
    version ||= detect_version
    committed = commit_release_prep!(version)
  end

  # 7. optional local CI via act
  maybe_run_local_ci_before_push!(committed) if @start_step <= 7

  # 8. ensure trunk synced
  if @start_step <= 8
    trunk = detect_trunk_branch
    feature = current_branch
    puts "Trunk branch detected: #{trunk}"
    ensure_trunk_synced_before_push!(trunk, feature)
  end

  # 9. push branches
  push! if @start_step <= 9

  # 10. monitor CI after push
  monitor_workflows_after_push! if @start_step <= 10

  # 11. merge feature into trunk and push
  if @start_step <= 11
    trunk ||= detect_trunk_branch
    feature ||= current_branch
    merge_feature_into_trunk_and_push!(trunk, feature)
  end

  # 12. checkout trunk and pull
  if @start_step <= 12
    trunk ||= detect_trunk_branch
    checkout!(trunk)
    pull!(trunk)
  end

  # 13. signing guidance and checks
  if @start_step <= 13
    if ENV.fetch("SKIP_GEM_SIGNING", "false").casecmp("false").zero?
      puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
      if Kettle::Dev::InputAdapter.tty?
        # In CI, avoid interactive prompts when no TTY is present (e.g., act or GitHub Actions "CI validation").
        # Non-interactive CI runs should not abort here; later signing checks are either stubbed in tests
        # or will be handled explicitly by ensure_signing_setup_or_skip!.
        print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
        ans = Kettle::Dev::InputAdapter.gets&.strip
        unless ans&.downcase&.start_with?("y")
          abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
        end
      else
        warn("Non-interactive shell detected (non-TTY); skipping interactive signing confirmation.")
      end
    end

    ensure_signing_setup_or_skip!
  end

  # 14. build
  if @start_step <= 14
    puts "Running build (you may be prompted for the signing key password)..."
    run_cmd!("bundle exec rake build")
  end

  # 15. checksums validate
  if @start_step <= 15
    run_cmd!("bin/gem_checksums")
    version ||= detect_version
    validate_checksums!(version, stage: "after build + gem_checksums")
  end

  # 16. release and validate
  if @start_step <= 16
    puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
    run_cmd!("bundle exec rake release")
    version ||= detect_version
    validate_checksums!(version, stage: "after release")
  end

  # 17. create GitHub release (optional)
  if @start_step <= 17
    version ||= detect_version
    maybe_create_github_release!(version)
  end

  # 18. push tags to remotes (new final step)
  push_tags! if @start_step <= 18

  # Final success message
  begin
    version ||= detect_version
    gem_name = detect_gem_name
    puts "\nšŸš€ Release #{gem_name} v#{version} Complete šŸš€"
  rescue StandardError
    # Fallback if detection fails for any reason
    puts "\nšŸš€ Release v#{version || "unknown"} Complete šŸš€"
  end
end