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.
- checksums.yaml +4 -4
- data/.claude/commands/update-changelog.md +367 -0
- data/.github/workflows/claude.yml +5 -0
- data/.overcommit.yml +43 -3
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +28 -4
- data/CONTRIBUTING.md +6 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +92 -72
- data/README.md +43 -15
- data/cpflow.gemspec +5 -5
- data/docs/ai-github-flow-prompt.md +61 -0
- data/docs/ci-automation.md +335 -28
- data/docs/commands.md +65 -4
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +14 -0
- data/lib/command/cleanup_images.rb +1 -1
- data/lib/command/cleanup_stale_apps.rb +1 -1
- data/lib/command/copy_image_from_upstream.rb +14 -3
- data/lib/command/exists.rb +13 -2
- data/lib/command/generate.rb +153 -4
- data/lib/command/generate_github_actions.rb +170 -0
- data/lib/command/generator_helpers.rb +31 -0
- data/lib/command/github_flow_readiness.rb +37 -0
- data/lib/command/run.rb +1 -1
- data/lib/command/terraform/generate.rb +1 -0
- data/lib/command/version.rb +1 -0
- data/lib/constants/exit_code.rb +1 -0
- data/lib/core/controlplane.rb +9 -7
- data/lib/core/controlplane_api_direct.rb +3 -3
- data/lib/core/github_flow_readiness/checks.rb +143 -0
- data/lib/core/github_flow_readiness_service.rb +453 -0
- data/lib/core/repo_introspection.rb +118 -0
- data/lib/core/terraform_config/dsl.rb +1 -1
- data/lib/core/terraform_config/local_variable.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +65 -3
- data/lib/generator_templates/Dockerfile +59 -3
- data/lib/generator_templates/controlplane.yml +27 -39
- data/lib/generator_templates/entrypoint.sh +1 -1
- data/lib/generator_templates/release_script.sh +23 -0
- data/lib/generator_templates/templates/app.yml +5 -8
- data/lib/generator_templates/templates/rails.yml +2 -11
- data/lib/generator_templates_sqlite/controlplane.yml +46 -0
- data/lib/generator_templates_sqlite/release_script.sh +25 -0
- data/lib/generator_templates_sqlite/templates/app.yml +15 -0
- data/lib/generator_templates_sqlite/templates/db.yml +6 -0
- data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
- data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
- data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
- data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
- data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
- data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
- data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
- data/rakelib/create_release.rake +662 -37
- data/script/check_command_docs +4 -2
- data/script/check_cpln_links +25 -11
- data/script/precommit/check_command_docs +22 -0
- data/script/precommit/check_cpln_links +21 -0
- data/script/precommit/check_trailing_newlines +68 -0
- data/script/precommit/get_changed_files +49 -0
- data/script/precommit/ruby_autofix +52 -0
- data/script/precommit/ruby_lint +33 -0
- metadata +52 -14
data/rakelib/create_release.rake
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
Release.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
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
|
|
443
|
+
"You have uncommitted code. Please commit or stash your changes before releasing."
|
|
54
444
|
else
|
|
55
|
-
"
|
|
445
|
+
"Git is required before releasing."
|
|
56
446
|
end
|
|
57
447
|
raise(error)
|
|
58
448
|
end
|
|
59
449
|
|
|
60
|
-
def
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|