cpflow 4.2.0 → 5.0.0.rc.0

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude.yml +5 -0
  4. data/.overcommit.yml +43 -3
  5. data/.rubocop.yml +3 -3
  6. data/CHANGELOG.md +28 -4
  7. data/CONTRIBUTING.md +6 -0
  8. data/Gemfile +8 -7
  9. data/Gemfile.lock +92 -72
  10. data/README.md +43 -15
  11. data/cpflow.gemspec +5 -5
  12. data/docs/ai-github-flow-prompt.md +61 -0
  13. data/docs/ci-automation.md +335 -28
  14. data/docs/commands.md +65 -4
  15. data/docs/releasing.md +153 -0
  16. data/lib/command/ai_github_flow_prompt.rb +47 -0
  17. data/lib/command/base.rb +14 -0
  18. data/lib/command/cleanup_images.rb +1 -1
  19. data/lib/command/cleanup_stale_apps.rb +1 -1
  20. data/lib/command/copy_image_from_upstream.rb +14 -3
  21. data/lib/command/exists.rb +13 -2
  22. data/lib/command/generate.rb +153 -4
  23. data/lib/command/generate_github_actions.rb +170 -0
  24. data/lib/command/generator_helpers.rb +31 -0
  25. data/lib/command/github_flow_readiness.rb +37 -0
  26. data/lib/command/run.rb +1 -1
  27. data/lib/command/terraform/generate.rb +1 -0
  28. data/lib/command/version.rb +1 -0
  29. data/lib/constants/exit_code.rb +1 -0
  30. data/lib/core/controlplane.rb +9 -7
  31. data/lib/core/controlplane_api_direct.rb +3 -3
  32. data/lib/core/github_flow_readiness/checks.rb +143 -0
  33. data/lib/core/github_flow_readiness_service.rb +453 -0
  34. data/lib/core/repo_introspection.rb +118 -0
  35. data/lib/core/terraform_config/dsl.rb +1 -1
  36. data/lib/core/terraform_config/local_variable.rb +1 -1
  37. data/lib/cpflow/version.rb +1 -1
  38. data/lib/cpflow.rb +65 -3
  39. data/lib/generator_templates/Dockerfile +59 -3
  40. data/lib/generator_templates/controlplane.yml +27 -39
  41. data/lib/generator_templates/entrypoint.sh +1 -1
  42. data/lib/generator_templates/release_script.sh +23 -0
  43. data/lib/generator_templates/templates/app.yml +5 -8
  44. data/lib/generator_templates/templates/rails.yml +2 -11
  45. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  46. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  47. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  48. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  49. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  50. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  51. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  52. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  53. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  54. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  58. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  61. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  62. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  66. data/rakelib/create_release.rake +662 -37
  67. data/script/check_command_docs +4 -2
  68. data/script/check_cpln_links +25 -11
  69. data/script/precommit/check_command_docs +22 -0
  70. data/script/precommit/check_cpln_links +21 -0
  71. data/script/precommit/check_trailing_newlines +68 -0
  72. data/script/precommit/get_changed_files +49 -0
  73. data/script/precommit/ruby_autofix +52 -0
  74. data/script/precommit/ruby_lint +33 -0
  75. metadata +52 -14
@@ -1,81 +1,706 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity
4
+ # rubocop:disable Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
5
+
6
+ require "bundler"
3
7
  require "English"
8
+ require "fileutils"
9
+ require "open3"
10
+ require "rubygems/version"
11
+ require "shellwords"
12
+ require "tempfile"
13
+ require "tmpdir"
14
+
15
+ Rake::Task[:release].clear if Rake::Task.task_defined?(:release)
16
+
17
+ desc("Releases the cpflow Ruby gem.
18
+
19
+ The recommended flow is changelog-first:
20
+ 1. Merge the CHANGELOG.md update for the target version.
21
+ 2. Run `bundle exec rake release`.
22
+ 3. Enter the RubyGems OTP when prompted.
23
+
24
+ With no version argument, the task reads the latest versioned CHANGELOG.md
25
+ header and uses it when it is newer than the current gem version. Otherwise,
26
+ it falls back to a patch bump.
27
+
28
+ 1st argument: Version (optional). Supported values:
29
+ patch, minor, major, 4.2.0, or 4.2.0.rc.1
30
+ 2nd argument: Dry run (true/false, default: false)
31
+ 3rd argument: Override version policy checks (true/false, default: false)
32
+
33
+ Environment variables:
34
+ VERBOSE=1
35
+ RUBYGEMS_OTP=<code>
36
+ RELEASE_VERSION_POLICY_OVERRIDE=true
37
+ GEM_RELEASE_MAX_RETRIES=<n>
38
+
39
+ Examples:
40
+ bundle exec rake release
41
+ bundle exec rake \"release[patch]\"
42
+ bundle exec rake \"release[4.2.0]\"
43
+ bundle exec rake \"release[4.2.0.rc.1]\"
44
+ bundle exec rake \"release[4.2.0,true]\"")
45
+ task :release, %i[version dry_run override_version_policy] do |_t, args|
46
+ args_hash = args.to_hash
47
+ gem_root = Release.gem_root
48
+ is_dry_run = Release.object_to_boolean(args_hash[:dry_run])
49
+ allow_version_policy_override = Release.version_policy_override_enabled?(args_hash[:override_version_policy])
50
+ rubygems_otp = ENV.fetch("RUBYGEMS_OTP", nil)
51
+ current_branch = Release.current_git_branch(gem_root)
52
+ released_gem_version = nil
53
+
54
+ Release.ensure_there_is_nothing_to_commit(gem_root)
55
+ Release.run_release_preflight_checks!(gem_root: gem_root, dry_run: is_dry_run)
4
56
 
5
- desc("Releases the gem package using the given version.
57
+ Release.with_release_checkout(gem_root: gem_root, dry_run: is_dry_run) do |release_root|
58
+ Release.update_the_local_project(release_root) unless is_dry_run
6
59
 
7
- IMPORTANT: the gem version must be in valid rubygem format (no dashes).
8
- This task depends on the gem-release ruby gem.
60
+ version_input = Release.resolve_version_input(args_hash.fetch(:version, ""), gem_root: release_root)
61
+ Release.validate_requested_version_input!(version_input)
9
62
 
10
- 1st argument: The new version in rubygem format (no dashes). Pass no argument to
11
- automatically perform a patch version bump.
12
- 2nd argument: Perform a dry run by passing 'true' as a second argument.
63
+ current_checkout_version = Release.current_gem_version(release_root)
64
+ target_gem_version = Release.compute_target_gem_version(
65
+ current_gem_version: current_checkout_version,
66
+ version_input: version_input
67
+ )
13
68
 
14
- Example: `rake create_release[2.1.0,false]`")
69
+ Release.ensure_release_branch_allowed!(
70
+ current_branch: current_branch,
71
+ target_gem_version: target_gem_version
72
+ )
15
73
 
74
+ Release.validate_release_version_policy!(
75
+ gem_root: release_root,
76
+ target_gem_version: target_gem_version,
77
+ allow_override: allow_version_policy_override,
78
+ fetch_tags: true
79
+ )
80
+
81
+ Release.confirm_release!(version: target_gem_version, gem_root: release_root) unless is_dry_run
82
+ Release.bump_gem_version!(gem_root: release_root, version_input: version_input)
83
+ Release.update_lockfile!(gem_root: release_root)
84
+
85
+ released_gem_version = Release.current_gem_version(release_root)
86
+
87
+ next if is_dry_run
88
+
89
+ Release.commit_tag_and_push!(gem_root: release_root, version: released_gem_version)
90
+ Release.publish_gem_with_retry(release_root, "cpflow", otp: rubygems_otp)
91
+ end
92
+
93
+ if is_dry_run
94
+ puts ""
95
+ puts "DRY RUN COMPLETE"
96
+ puts "Version would be bumped to: #{released_gem_version}"
97
+ puts "To release for real, run: bundle exec rake \"release[#{released_gem_version}]\""
98
+ else
99
+ Release.sync_github_release_after_publish(gem_root: gem_root, gem_version: released_gem_version, dry_run: false)
100
+ puts ""
101
+ puts "RELEASE COMPLETE"
102
+ puts "Published cpflow #{released_gem_version} to RubyGems.org."
103
+ end
104
+ end
105
+
106
+ desc("Compatibility alias for the old release task. Prefer `bundle exec rake release`.")
16
107
  task :create_release, %i[gem_version dry_run] do |_t, args|
17
108
  args_hash = args.to_hash
109
+ Rake::Task[:release].invoke(args_hash.fetch(:gem_version, ""), args_hash[:dry_run])
110
+ end
18
111
 
19
- is_dry_run = Release.object_to_boolean(args_hash[:dry_run])
20
- gem_version = args_hash.fetch(:gem_version, "").strip
112
+ desc("Creates or updates the GitHub release notes from CHANGELOG.md.
113
+
114
+ 1st argument: Gem version in RubyGems format, e.g. 4.2.0 or 4.2.0.rc.1
115
+ 2nd argument: Dry run (true/false, default: false)
116
+
117
+ Examples:
118
+ bundle exec rake \"sync_github_release[4.2.0]\"
119
+ bundle exec rake \"sync_github_release[4.2.0.rc.1]\"
120
+ bundle exec rake \"sync_github_release[4.2.0,true]\"")
121
+ task :sync_github_release, %i[gem_version dry_run] do |_t, args|
122
+ args_hash = args.to_hash
21
123
  gem_root = Release.gem_root
124
+ is_dry_run = Release.object_to_boolean(args_hash[:dry_run])
125
+ requested_gem_version = args_hash[:gem_version].to_s.strip
126
+
127
+ if requested_gem_version.empty?
128
+ abort "gem_version is required. Usage: bundle exec rake \"sync_github_release[4.2.0]\""
129
+ end
130
+
131
+ Release.validate_requested_version_input!(requested_gem_version)
22
132
 
23
- Release.update_the_local_project
24
- Release.ensure_there_is_nothing_to_commit
25
- Release.sh_in_dir(gem_root,
26
- "gem bump --no-commit #{gem_version == '' ? '' : %(--version #{gem_version})}")
27
- Release.sh_in_dir(gem_root, "bundle install")
28
- Release.sh_in_dir(gem_root, "git commit -am 'Bump version to #{gem_version}'")
29
- Release.sh_in_dir(gem_root, "git push")
133
+ if is_dry_run
134
+ if Release.changelog_dirty?(gem_root: gem_root)
135
+ abort "DRY RUN: CHANGELOG.md has uncommitted changes. Commit or stash it before syncing."
136
+ end
137
+ else
138
+ Release.ensure_changelog_committed!(gem_root: gem_root)
139
+ end
30
140
 
31
- # See https://github.com/svenfuchs/gem-release
32
- Release.release_the_new_gem_version unless is_dry_run
141
+ Release.verify_gh_auth(gem_root: gem_root)
142
+ release_context = Release.prepare_github_release_context(gem_root: gem_root, gem_version: requested_gem_version)
143
+ Release.publish_or_update_github_release(gem_root: gem_root, release_context: release_context, dry_run: is_dry_run)
33
144
  end
34
145
 
35
146
  module Release
36
147
  extend FileUtils
148
+ extend Rake::FileUtilsExt if defined?(Rake::FileUtilsExt)
149
+
150
+ PRERELEASE_PATTERN = /\.(test|beta|alpha|rc|pre)\./i
151
+ VERSION_PATTERN = /\A\d+\.\d+\.\d+(\.(test|beta|alpha|rc|pre)\.\d+)?\z/i
152
+
37
153
  class << self
38
154
  def gem_root
39
155
  File.expand_path("..", __dir__)
40
156
  end
41
157
 
42
- # Executes a string or an array of strings in a shell in the given directory in an unbundled environment
43
- def sh_in_dir(dir, *shell_commands)
44
- shell_commands.flatten.each { |shell_command| sh %(cd #{dir} && #{shell_command.strip}) }
158
+ def object_to_boolean(value)
159
+ [true, "true", "yes", 1, "1", "t"].include?(value.instance_of?(String) ? value.downcase : value)
160
+ end
161
+
162
+ def version_policy_override_enabled?(override_flag)
163
+ object_to_boolean(override_flag) || object_to_boolean(ENV.fetch("RELEASE_VERSION_POLICY_OVERRIDE", nil))
164
+ end
165
+
166
+ def semver_keyword?(value)
167
+ %w[patch minor major].include?(value.to_s.strip.downcase)
168
+ end
169
+
170
+ def prerelease_version?(version)
171
+ version.to_s.match?(PRERELEASE_PATTERN)
172
+ end
173
+
174
+ def validate_requested_version_input!(version_input)
175
+ return if semver_keyword?(version_input)
176
+ return if version_input.to_s.match?(VERSION_PATTERN)
177
+
178
+ abort <<~ERROR
179
+ Invalid version argument: #{version_input.inspect}
180
+
181
+ Use:
182
+ - patch, minor, or major
183
+ - explicit version: 4.2.0
184
+ - explicit prerelease: 4.2.0.rc.1
185
+ ERROR
45
186
  end
46
187
 
47
- def ensure_there_is_nothing_to_commit
48
- status = `git status --porcelain`
188
+ def parse_gem_version_components(gem_version)
189
+ match = gem_version.to_s.strip.match(/\A(\d+)\.(\d+)\.(\d+)(?:\.(test|beta|alpha|rc|pre)\.(\d+))?\z/i)
190
+ abort "Unsupported gem version format: #{gem_version.inspect}" unless match
191
+
192
+ {
193
+ major: match[1].to_i,
194
+ minor: match[2].to_i,
195
+ patch: match[3].to_i,
196
+ prerelease_type: match[4]&.downcase,
197
+ prerelease_index: match[5]&.to_i
198
+ }
199
+ end
200
+
201
+ def compute_target_gem_version(current_gem_version:, version_input:)
202
+ return version_input unless semver_keyword?(version_input)
203
+
204
+ version = parse_gem_version_components(current_gem_version)
205
+
206
+ case version_input.to_s.strip.downcase
207
+ when "patch"
208
+ return "#{version[:major]}.#{version[:minor]}.#{version[:patch]}" if version[:prerelease_type]
209
+
210
+ "#{version[:major]}.#{version[:minor]}.#{version[:patch] + 1}"
211
+ when "minor"
212
+ "#{version[:major]}.#{version[:minor] + 1}.0"
213
+ when "major"
214
+ "#{version[:major] + 1}.0.0"
215
+ end
216
+ end
217
+
218
+ def current_gem_version(gem_root)
219
+ version_file = File.join(gem_root, "lib", "cpflow", "version.rb")
220
+ content = File.read(version_file)
221
+ match = content.match(/VERSION = "([^"]+)"/)
222
+ abort "Unable to read current gem version from #{version_file}" unless match
223
+
224
+ match[1]
225
+ end
226
+
227
+ def extract_latest_changelog_version(gem_root:)
228
+ changelog_path = File.join(gem_root, "CHANGELOG.md")
229
+ return nil unless File.exist?(changelog_path)
230
+
231
+ File.readlines(changelog_path).each do |line|
232
+ match = line.match(/^## \[([^\]]+)\]/)
233
+ next unless match
234
+
235
+ version = match[1].strip
236
+ next if version == "Unreleased"
237
+
238
+ return version if version.match?(VERSION_PATTERN)
239
+ end
240
+
241
+ nil
242
+ end
243
+
244
+ def extract_changelog_section(gem_root:, version:)
245
+ changelog_path = File.join(gem_root, "CHANGELOG.md")
246
+ lines = File.readlines(changelog_path)
247
+ section_header = /^## \[#{Regexp.escape(version)}\]/
248
+ start_index = lines.index { |line| line.match?(section_header) }
249
+ return nil unless start_index
250
+
251
+ end_index = ((start_index + 1)...lines.length).find { |idx| lines[idx].start_with?("## [") } || lines.length
252
+ content = lines[(start_index + 1)...end_index].join.strip
253
+ return nil if content.empty?
254
+
255
+ content
256
+ end
257
+
258
+ def resolve_version_input(version_input, gem_root:)
259
+ stripped = version_input.to_s.strip
260
+ return stripped unless stripped.empty?
261
+
262
+ changelog_version = extract_latest_changelog_version(gem_root: gem_root)
263
+ current_version = current_gem_version(gem_root)
264
+
265
+ if changelog_version && Gem::Version.new(changelog_version) > Gem::Version.new(current_version)
266
+ puts "Found CHANGELOG.md version: #{changelog_version} (current: #{current_version})"
267
+ return changelog_version
268
+ end
269
+
270
+ if changelog_version &&
271
+ Gem::Version.new(changelog_version) == Gem::Version.new(current_version) &&
272
+ !version_tagged?(gem_root, changelog_version)
273
+ puts "Found untagged CHANGELOG.md version: #{changelog_version} (current: #{current_version})"
274
+ return changelog_version
275
+ end
276
+
277
+ puts "No new version found in CHANGELOG.md (latest: #{changelog_version || 'none'}, current: #{current_version})."
278
+ puts "Falling back to patch bump."
279
+ "patch"
280
+ end
281
+
282
+ def parse_release_tag_to_gem_version(tag)
283
+ stable_match = tag.match(/\Av(\d+\.\d+\.\d+)\z/)
284
+ return stable_match[1] if stable_match
285
+
286
+ prerelease_with_dot = tag.match(/\Av(\d+\.\d+\.\d+)\.(test|beta|alpha|rc|pre)\.(\d+)\z/i)
287
+ if prerelease_with_dot
288
+ return "#{prerelease_with_dot[1]}.#{prerelease_with_dot[2].downcase}.#{prerelease_with_dot[3]}"
289
+ end
290
+
291
+ prerelease_with_dash = tag.match(/\Av(\d+\.\d+\.\d+)-(test|beta|alpha|rc|pre)\.(\d+)\z/i)
292
+ return unless prerelease_with_dash
293
+
294
+ "#{prerelease_with_dash[1]}.#{prerelease_with_dash[2].downcase}.#{prerelease_with_dash[3]}"
295
+ end
296
+
297
+ def tagged_release_gem_versions(gem_root, fetch_tags: true)
298
+ if fetch_tags
299
+ fetch_output, fetch_status = Open3.capture2e("git", "-C", gem_root, "fetch", "--tags", "--quiet")
300
+ abort "Unable to fetch tags for version validation.\n\n#{fetch_output.strip}" unless fetch_status.success?
301
+ end
302
+
303
+ tags_output, tags_status = Open3.capture2e("git", "-C", gem_root, "tag", "-l", "v*")
304
+ abort "Unable to list git tags for version validation.\n\n#{tags_output.strip}" unless tags_status.success?
305
+
306
+ tags_output.lines.map(&:strip).filter_map { |tag| parse_release_tag_to_gem_version(tag) }.uniq
307
+ end
308
+
309
+ def version_tagged?(gem_root, version)
310
+ tagged_release_gem_versions(gem_root, fetch_tags: true).include?(version)
311
+ end
312
+
313
+ def version_bump_type(previous_stable_gem_version:, target_gem_version:)
314
+ previous = parse_gem_version_components(previous_stable_gem_version)
315
+ target = parse_gem_version_components(target_gem_version)
316
+
317
+ return :major if target[:major] > previous[:major]
318
+ return :minor if target[:major] == previous[:major] && target[:minor] > previous[:minor]
319
+ return :patch if target[:major] == previous[:major] &&
320
+ target[:minor] == previous[:minor] &&
321
+ target[:patch] > previous[:patch]
322
+
323
+ :none
324
+ end
325
+
326
+ def expected_bump_type_from_changelog_section(changelog_section)
327
+ section = changelog_section.to_s
328
+ return :major if section.match?(/^####?\s+(?:Breaking(?:\s+Changes?)?)\b/i)
329
+ return :minor if section.match?(/^####?\s+(Added|New\s+Features?|Features?|Enhancements?)\b/i)
330
+
331
+ patch_headings = /^####?\s+(Fixed|Fixes|Bug\s+Fixes?|Security|Improved|Changed|Deprecated|Removed)\b/i
332
+ return :patch if section.match?(patch_headings)
333
+
334
+ nil
335
+ end
336
+
337
+ def handle_version_policy_violation!(message:, allow_override:)
338
+ if allow_override
339
+ puts "VERSION POLICY OVERRIDE: #{message}"
340
+ return
341
+ end
342
+
343
+ abort message
344
+ end
49
345
 
346
+ def validate_release_version_policy!(gem_root:, target_gem_version:, allow_override:, fetch_tags: true)
347
+ tagged_versions = tagged_release_gem_versions(gem_root, fetch_tags: fetch_tags)
348
+ latest_tagged_version = tagged_versions.max_by { |version| Gem::Version.new(version) }
349
+
350
+ if latest_tagged_version && Gem::Version.new(target_gem_version) <= Gem::Version.new(latest_tagged_version)
351
+ handle_version_policy_violation!(
352
+ message: "Requested version #{target_gem_version} must be greater than latest tag #{latest_tagged_version}.",
353
+ allow_override: allow_override
354
+ )
355
+ end
356
+
357
+ if prerelease_version?(target_gem_version) && latest_tagged_version
358
+ target_components = parse_gem_version_components(target_gem_version)
359
+ latest_components = parse_gem_version_components(latest_tagged_version)
360
+ same_release_base = target_components[:major] == latest_components[:major] &&
361
+ target_components[:minor] == latest_components[:minor] &&
362
+ target_components[:patch] == latest_components[:patch]
363
+
364
+ return if same_release_base && prerelease_version?(latest_tagged_version)
365
+ end
366
+
367
+ latest_stable_version = tagged_versions.reject { |version| prerelease_version?(version) }
368
+ .max_by { |version| Gem::Version.new(version) }
369
+ return unless latest_stable_version
370
+
371
+ actual_bump_type = version_bump_type(
372
+ previous_stable_gem_version: latest_stable_version,
373
+ target_gem_version: target_gem_version
374
+ )
375
+
376
+ if actual_bump_type == :none
377
+ handle_version_policy_violation!(
378
+ message: "Requested version #{target_gem_version} is not a bump over latest stable #{latest_stable_version}.",
379
+ allow_override: allow_override
380
+ )
381
+ return if allow_override
382
+ end
383
+
384
+ return if prerelease_version?(target_gem_version)
385
+
386
+ changelog_section = extract_changelog_section(gem_root: gem_root, version: target_gem_version)
387
+ return unless changelog_section
388
+
389
+ expected_bump_type = expected_bump_type_from_changelog_section(changelog_section)
390
+ return unless expected_bump_type
391
+ return if actual_bump_type == expected_bump_type
392
+
393
+ handle_version_policy_violation!(
394
+ message: "Version bump mismatch for #{target_gem_version}: CHANGELOG implies #{expected_bump_type}, " \
395
+ "but the version bump is #{actual_bump_type} from #{latest_stable_version}.",
396
+ allow_override: allow_override
397
+ )
398
+ end
399
+
400
+ def confirm_release!(version:, gem_root:)
401
+ has_changelog = extract_changelog_section(gem_root: gem_root, version: version)
402
+
403
+ puts ""
404
+ puts "Release confirmation"
405
+ puts " Version: #{version}"
406
+ puts " Changelog: #{has_changelog ? 'section found' : 'missing; GitHub release sync will be skipped'}"
407
+ print "Proceed with release? [y/N] "
408
+ $stdout.flush
409
+ answer = $stdin.gets&.strip&.downcase
410
+ abort "Release aborted." unless answer == "y"
411
+ end
412
+
413
+ def current_git_branch(gem_root)
414
+ output, status = Open3.capture2e("git", "-C", gem_root, "rev-parse", "--abbrev-ref", "HEAD")
415
+ abort "Failed to determine current git branch.\n\n#{output}" unless status.success?
416
+
417
+ output.strip
418
+ end
419
+
420
+ def ensure_release_branch_allowed!(current_branch:, target_gem_version:)
421
+ return if prerelease_version?(target_gem_version)
422
+ return if current_branch == "main"
423
+
424
+ abort <<~ERROR
425
+ Release must be run from the main branch.
426
+
427
+ Current branch: #{current_branch}
428
+
429
+ For stable releases:
430
+ git checkout main
431
+ git pull --rebase
432
+ bundle exec rake release
433
+
434
+ Pre-release versions such as #{target_gem_version}.rc.0 may be released from non-main branches.
435
+ ERROR
436
+ end
437
+
438
+ def ensure_there_is_nothing_to_commit(gem_root = self.gem_root)
439
+ status = `git -C #{Shellwords.escape(gem_root)} status --porcelain`
50
440
  return if $CHILD_STATUS.success? && status == ""
51
441
 
52
442
  error = if $CHILD_STATUS.success?
53
- "You have uncommitted code. Please commit or stash your changes before continuing"
443
+ "You have uncommitted code. Please commit or stash your changes before releasing."
54
444
  else
55
- "You do not have Git installed. Please install Git, and commit your changes before continuing"
445
+ "Git is required before releasing."
56
446
  end
57
447
  raise(error)
58
448
  end
59
449
 
60
- def object_to_boolean(value)
61
- [true, "true", "yes", 1, "1", "t"].include?(value.instance_of?(String) ? value.downcase : value)
450
+ def verify_gem_release_available!
451
+ output, status = Open3.capture2e("bundle", "exec", "gem", "bump", "--help")
452
+ abort "gem-release is required. Run `bundle install`.\n\n#{output}" unless status.success?
453
+ end
454
+
455
+ def github_repo_slug(gem_root)
456
+ origin_url, status = Open3.capture2e("git", "-C", gem_root, "remote", "get-url", "origin")
457
+ abort "Unable to determine git origin URL.\n\n#{origin_url}" unless status.success?
458
+
459
+ match = origin_url.strip.match(%r{github\.com[:/](?<repo>[^/]+/[^/]+?)(?:\.git)?\z})
460
+ abort "Unable to determine GitHub repository from origin URL #{origin_url.inspect}" unless match
461
+
462
+ match[:repo]
463
+ end
464
+
465
+ def capture_gh_output(*args)
466
+ Open3.capture2e("gh", *args)
467
+ rescue Errno::ENOENT
468
+ abort "GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry."
469
+ end
470
+
471
+ def verify_gh_auth(gem_root:)
472
+ result, status = capture_gh_output("auth", "status")
473
+ abort "GitHub CLI authentication required. Run `gh auth login`.\n\n#{result}" unless status.success?
474
+
475
+ repo_slug = github_repo_slug(gem_root)
476
+ permission_result, permission_status = capture_gh_output("api", "repos/#{repo_slug}", "--jq", ".permissions.push")
477
+
478
+ unless permission_status.success?
479
+ abort "GitHub CLI authenticated, but write access check failed for #{repo_slug}.\n\n#{permission_result}"
480
+ end
481
+
482
+ unless permission_result.strip == "true"
483
+ abort "GitHub CLI account/token does not have write access to #{repo_slug}."
484
+ end
485
+
486
+ puts "GitHub CLI authenticated with write access to #{repo_slug}."
487
+ end
488
+
489
+ def run_release_preflight_checks!(gem_root:, dry_run:)
490
+ verify_gem_release_available!
491
+ return if dry_run
492
+
493
+ verify_gh_auth(gem_root: gem_root)
494
+ end
495
+
496
+ def sh_in_dir(dir, *shell_commands)
497
+ Dir.chdir(dir) do
498
+ shell_commands.flatten.each { |shell_command| sh(shell_command.strip) }
499
+ end
500
+ end
501
+
502
+ def sh_args_in_dir(dir, *command_args, env: nil)
503
+ Dir.chdir(dir) do
504
+ env ? sh(env, *command_args) : sh(*command_args)
505
+ end
62
506
  end
63
507
 
64
- def update_the_local_project
65
- puts "Pulling latest commits from remote repository"
508
+ def unbundled_sh_in_dir(dir, *shell_commands)
509
+ Dir.chdir(dir) do
510
+ Bundler.with_unbundled_env do
511
+ shell_commands.flatten.each { |shell_command| sh(shell_command.strip) }
512
+ end
513
+ end
514
+ end
66
515
 
67
- sh_in_dir(gem_root, "git pull --rebase")
68
- raise "Failed in pulling latest changes from default remote repository." unless $CHILD_STATUS.success?
516
+ def update_the_local_project(gem_root = self.gem_root)
517
+ puts "Pulling latest commits from remote repository."
518
+ sh_args_in_dir(gem_root, "git", "pull", "--rebase")
69
519
  rescue Errno::ENOENT
70
- raise "Ensure you have Git and Bundler installed before continuing."
520
+ raise "Ensure you have Git and Bundler installed before releasing."
521
+ end
522
+
523
+ def with_release_checkout(gem_root:, dry_run:)
524
+ return yield(gem_root) unless dry_run
525
+
526
+ Dir.mktmpdir("cpflow-release-dry-run") do |tmpdir|
527
+ worktree_dir = File.join(tmpdir, "worktree")
528
+ sh_args_in_dir(gem_root, "git", "worktree", "add", "--detach", worktree_dir, "HEAD")
529
+ begin
530
+ yield(worktree_dir)
531
+ ensure
532
+ sh_args_in_dir(gem_root, "git", "worktree", "remove", "--force", worktree_dir)
533
+ end
534
+ end
535
+ end
536
+
537
+ def bump_gem_version!(gem_root:, version_input:)
538
+ action = semver_keyword?(version_input) ? "Bumping #{version_input}" : "Setting"
539
+ puts "#{action} cpflow gem version..."
540
+ sh_args_in_dir(gem_root, "bundle", "exec", "gem", "bump", "--no-commit", "--version", version_input)
541
+ end
542
+
543
+ def update_lockfile!(gem_root:)
544
+ quiet_flag = ENV["VERBOSE"] == "1" ? "" : " --quiet"
545
+ unbundled_sh_in_dir(gem_root, "bundle install#{quiet_flag}")
546
+ end
547
+
548
+ def commit_tag_and_push!(gem_root:, version:)
549
+ sh_args_in_dir(gem_root, "git", "add", "-A", "Gemfile.lock", "lib/cpflow/version.rb")
550
+
551
+ _git_diff_output, git_diff_status = Open3.capture2e("git", "-C", gem_root, "diff", "--cached", "--quiet")
552
+ if git_diff_status.success?
553
+ puts "No version changes to commit; version is already #{version}."
554
+ else
555
+ sh_args_in_dir(gem_root, "git", "commit", "-m", "Bump version to #{version}")
556
+ end
557
+
558
+ tag_name = "v#{version}"
559
+ tag_exists = system("git", "-C", gem_root, "rev-parse", "--verify", "--quiet", "refs/tags/#{tag_name}",
560
+ out: File::NULL, err: File::NULL)
561
+ abort "Unable to verify git tag #{tag_name}." if tag_exists.nil?
562
+
563
+ if tag_exists
564
+ puts "Git tag #{tag_name} already exists; skipping tag creation."
565
+ else
566
+ sh_args_in_dir(gem_root, "git", "tag", tag_name)
567
+ end
568
+
569
+ sh_args_in_dir(gem_root, "git", "push")
570
+ sh_args_in_dir(gem_root, "git", "push", "--tags")
571
+ end
572
+
573
+ def normalize_otp_code(otp)
574
+ return nil if otp.nil?
575
+
576
+ normalized = otp.to_s.strip
577
+ abort "Invalid RubyGems OTP. Expected digits only." unless normalized.match?(/\A\d+\z/)
578
+
579
+ normalized
580
+ end
581
+
582
+ def prompt_for_otp
583
+ print "Enter RubyGems OTP code: "
584
+ $stdout.flush
585
+ otp = $stdin.gets&.strip
586
+ abort "No RubyGems OTP provided. Aborting." if otp.nil? || otp.empty?
587
+
588
+ normalize_otp_code(otp)
589
+ end
590
+
591
+ def publish_gem_with_retry(dir, gem_name, otp: nil, max_retries: ENV.fetch("GEM_RELEASE_MAX_RETRIES", "3").to_i)
592
+ puts ""
593
+ puts "Publishing #{gem_name} gem to RubyGems.org..."
594
+ current_otp = normalize_otp_code(otp)
595
+ current_otp ||= prompt_for_otp
596
+
597
+ retry_count = 0
598
+ loop do
599
+ gem_release_env = { "GEM_HOST_OTP_CODE" => current_otp }
600
+ sh_args_in_dir(dir, "bundle", "exec", "gem", "release", env: gem_release_env)
601
+ return current_otp
602
+ rescue RuntimeError, IOError => e
603
+ retry_count += 1
604
+ raise e if retry_count >= max_retries
605
+
606
+ puts "RubyGems release failed (attempt #{retry_count}/#{max_retries})."
607
+ puts "Error: #{e.class}: #{e.message}"
608
+ puts "Enter a fresh OTP to retry."
609
+ current_otp = prompt_for_otp
610
+ end
611
+ end
612
+
613
+ def changelog_dirty?(gem_root:)
614
+ changes_output, status = Open3.capture2e("git", "-C", gem_root, "status", "--porcelain", "--", "CHANGELOG.md")
615
+ abort "Unable to check CHANGELOG.md status.\n\n#{changes_output.strip}" unless status.success?
616
+
617
+ !changes_output.strip.empty?
618
+ end
619
+
620
+ def ensure_changelog_committed!(gem_root:)
621
+ return unless changelog_dirty?(gem_root: gem_root)
622
+
623
+ abort "CHANGELOG.md has uncommitted changes. Commit or stash it before syncing GitHub releases."
624
+ end
625
+
626
+ def ensure_git_tag_exists!(gem_root:, tag:)
627
+ fetch_output, fetch_status = Open3.capture2e("git", "-C", gem_root, "fetch", "--tags", "--quiet")
628
+ unless fetch_status.success?
629
+ abort "Unable to fetch git tags before verifying #{tag.inspect}.\n\n#{fetch_output.strip}"
630
+ end
631
+
632
+ tag_ref = "refs/tags/#{tag}"
633
+ tag_exists = system("git", "-C", gem_root, "rev-parse", "--verify", "--quiet", tag_ref,
634
+ out: File::NULL, err: File::NULL)
635
+ abort "Unable to run git to verify tag #{tag.inspect}." if tag_exists.nil?
636
+ return if tag_exists
637
+
638
+ abort "Git tag #{tag.inspect} was not found locally or remotely."
639
+ end
640
+
641
+ def prepare_github_release_context(gem_root:, gem_version:)
642
+ notes = extract_changelog_section(gem_root: gem_root, version: gem_version)
643
+ abort "Could not find `## [#{gem_version}]` in CHANGELOG.md. Add that section and retry." unless notes
644
+
645
+ {
646
+ notes: notes,
647
+ prerelease: prerelease_version?(gem_version),
648
+ tag: "v#{gem_version}",
649
+ title: "v#{gem_version}"
650
+ }
651
+ end
652
+
653
+ def publish_or_update_github_release(gem_root:, release_context:, dry_run:)
654
+ ensure_git_tag_exists!(gem_root: gem_root, tag: release_context[:tag])
655
+
656
+ if dry_run
657
+ puts "DRY RUN: Would create or update GitHub release #{release_context[:tag]}."
658
+ return
659
+ end
660
+
661
+ Tempfile.create(["cpflow-release-notes-", ".md"]) do |tmp|
662
+ tmp.write(release_context[:notes])
663
+ tmp.flush
664
+
665
+ release_exists = system("gh", "release", "view", release_context[:tag],
666
+ chdir: gem_root,
667
+ out: File::NULL,
668
+ err: File::NULL)
669
+ abort "Unable to run `gh`." if release_exists.nil?
670
+
671
+ release_command = github_release_command(release_context: release_context, notes_file: tmp.path,
672
+ release_exists: release_exists)
673
+ sh_args_in_dir(gem_root, *release_command)
674
+ end
675
+ end
676
+
677
+ def github_release_command(release_context:, notes_file:, release_exists:)
678
+ if release_exists
679
+ return ["gh", "release", "edit", release_context[:tag], "--title", release_context[:title],
680
+ "--notes-file", notes_file, "--prerelease=#{release_context[:prerelease]}"]
681
+ end
682
+
683
+ command = ["gh", "release", "create", release_context[:tag], "--verify-tag", "--title",
684
+ release_context[:title], "--notes-file", notes_file]
685
+ command << "--prerelease" if release_context[:prerelease]
686
+ command
71
687
  end
72
688
 
73
- def release_the_new_gem_version
74
- puts "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
75
- puts "Use the OTP for RubyGems!"
76
- puts "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
689
+ def sync_github_release_after_publish(gem_root:, gem_version:, dry_run:)
690
+ section = extract_changelog_section(gem_root: gem_root, version: gem_version)
691
+ unless section
692
+ puts ""
693
+ puts "Skipping GitHub release: no CHANGELOG.md section for #{gem_version}."
694
+ puts "After adding and committing the changelog section, run:"
695
+ puts "bundle exec rake \"sync_github_release[#{gem_version}]\""
696
+ return
697
+ end
77
698
 
78
- sh_in_dir(gem_root, "gem release --push --tag")
699
+ verify_gh_auth(gem_root: gem_root)
700
+ release_context = prepare_github_release_context(gem_root: gem_root, gem_version: gem_version)
701
+ publish_or_update_github_release(gem_root: gem_root, release_context: release_context, dry_run: dry_run)
79
702
  end
80
703
  end
81
704
  end
705
+ # rubocop:enable Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity
706
+ # rubocop:enable Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity