puma-release 0.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +114 -0
  4. data/exe/puma-release +7 -0
  5. data/lib/puma_release/agent_client.rb +163 -0
  6. data/lib/puma_release/build_support.rb +46 -0
  7. data/lib/puma_release/changelog_generator.rb +171 -0
  8. data/lib/puma_release/changelog_validator.rb +85 -0
  9. data/lib/puma_release/ci_checker.rb +108 -0
  10. data/lib/puma_release/cli.rb +52 -0
  11. data/lib/puma_release/commands/build.rb +68 -0
  12. data/lib/puma_release/commands/github.rb +76 -0
  13. data/lib/puma_release/commands/prepare.rb +178 -0
  14. data/lib/puma_release/commands/run.rb +51 -0
  15. data/lib/puma_release/context.rb +167 -0
  16. data/lib/puma_release/contributor_resolver.rb +52 -0
  17. data/lib/puma_release/error.rb +5 -0
  18. data/lib/puma_release/events.rb +18 -0
  19. data/lib/puma_release/git_repo.rb +169 -0
  20. data/lib/puma_release/github_client.rb +163 -0
  21. data/lib/puma_release/link_reference_builder.rb +49 -0
  22. data/lib/puma_release/options.rb +47 -0
  23. data/lib/puma_release/release_range.rb +69 -0
  24. data/lib/puma_release/repo_files.rb +85 -0
  25. data/lib/puma_release/shell.rb +107 -0
  26. data/lib/puma_release/stage_detector.rb +66 -0
  27. data/lib/puma_release/ui.rb +36 -0
  28. data/lib/puma_release/upgrade_guide_writer.rb +106 -0
  29. data/lib/puma_release/version.rb +5 -0
  30. data/lib/puma_release/version_recommender.rb +151 -0
  31. data/lib/puma_release.rb +28 -0
  32. data/test/test_helper.rb +72 -0
  33. data/test/unit/agent_client_test.rb +116 -0
  34. data/test/unit/build_command_test.rb +23 -0
  35. data/test/unit/build_support_test.rb +6 -0
  36. data/test/unit/changelog_validator_test.rb +42 -0
  37. data/test/unit/context_test.rb +209 -0
  38. data/test/unit/contributor_resolver_test.rb +47 -0
  39. data/test/unit/git_repo_test.rb +169 -0
  40. data/test/unit/github_client_test.rb +90 -0
  41. data/test/unit/github_command_test.rb +153 -0
  42. data/test/unit/options_test.rb +17 -0
  43. data/test/unit/prepare_test.rb +136 -0
  44. data/test/unit/repo_files_test.rb +119 -0
  45. data/test/unit/run_test.rb +32 -0
  46. data/test/unit/shell_test.rb +29 -0
  47. data/test/unit/stage_detector_test.rb +72 -0
  48. metadata +143 -0
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PumaRelease
6
+ class CiChecker
7
+ FAILURE_CONCLUSIONS = %w[failure cancelled timed_out action_required startup_failure stale].freeze
8
+ PENDING_STATUSES = %w[queued in_progress pending waiting requested].freeze
9
+
10
+ attr_reader :context
11
+
12
+ def initialize(context)
13
+ @context = context
14
+ end
15
+
16
+ def ensure_green!(sha)
17
+ debug("ensure_green! called with sha=#{sha}")
18
+ status = combined_status(sha)
19
+ debug("combined_status returned: #{status.inspect}")
20
+ return context.ui.info("CI is green.") if status == :success
21
+ return handle_unknown if status == :unknown
22
+
23
+ raise Error, "CI for #{sha[0, 12]} is #{status}. Stop and investigate before releasing."
24
+ end
25
+
26
+ private
27
+
28
+ def combined_status(sha)
29
+ status_url = "repos/#{context.metadata_repo}/commits/#{sha}/status"
30
+ runs_url = "repos/#{context.metadata_repo}/commits/#{sha}/check-runs"
31
+
32
+ debug("fetching commit status from: #{status_url}")
33
+ statuses = gh_json("gh", "api", status_url) || {}
34
+ debug("commit status response keys: #{statuses.keys.inspect}")
35
+ debug("commit status state: #{statuses["state"].inspect}")
36
+ debug("statuses array length: #{Array(statuses["statuses"]).length}")
37
+ Array(statuses["statuses"]).each_with_index do |item, i|
38
+ debug(" status[#{i}]: context=#{item["context"].inspect} state=#{item["state"].inspect}")
39
+ end
40
+
41
+ debug("fetching check-runs from: #{runs_url}")
42
+ runs = gh_json("gh", "api", runs_url) || {}
43
+ debug("check-runs response keys: #{runs.keys.inspect}")
44
+ debug("check_runs array length: #{Array(runs["check_runs"]).length}")
45
+ Array(runs["check_runs"]).each_with_index do |run, i|
46
+ debug(" check_run[#{i}]: name=#{run["name"].inspect} status=#{run["status"].inspect} conclusion=#{run["conclusion"].inspect}")
47
+ end
48
+
49
+ contexts = Array(statuses["statuses"]).map { |item| item.fetch("state") }
50
+ conclusions = Array(runs["check_runs"]).flat_map { |run| [run["status"], run["conclusion"]].compact }
51
+ values = contexts + conclusions
52
+
53
+ debug("contexts from statuses: #{contexts.inspect}")
54
+ debug("conclusions from check-runs: #{conclusions.inspect}")
55
+ debug("combined values: #{values.inspect}")
56
+
57
+ if values.empty?
58
+ debug("returning :unknown — no values found")
59
+ return :unknown
60
+ end
61
+
62
+ failure_values = values.select { |v| FAILURE_CONCLUSIONS.include?(v) || v == "error" }
63
+ if failure_values.any?
64
+ debug("returning :failure — found failure values: #{failure_values.inspect}")
65
+ return :failure
66
+ end
67
+
68
+ pending_values = values.select { |v| PENDING_STATUSES.include?(v) }
69
+ if pending_values.any?
70
+ debug("returning :pending — found pending values: #{pending_values.inspect}")
71
+ return :pending
72
+ end
73
+
74
+ unrecognized = values - ["success", "neutral", "skipped", "completed"]
75
+ if unrecognized.empty?
76
+ debug("returning :success — all values recognized as success: #{values.inspect}")
77
+ return :success
78
+ end
79
+
80
+ debug("returning :unknown — unrecognized values: #{unrecognized.inspect}")
81
+ :unknown
82
+ end
83
+
84
+ def handle_unknown
85
+ return context.ui.warn("Could not determine CI status; continuing because --allow-unknown-ci was set.") if context.allow_unknown_ci?
86
+ raise Error, "Could not determine CI status. Re-run with --allow-unknown-ci if you want to proceed anyway."
87
+ end
88
+
89
+ def gh_json(*command)
90
+ debug("gh_json running: #{command.join(" ")}")
91
+ result = context.shell.run(*command, allow_failure: true)
92
+ debug("gh_json exit status: #{result.exitstatus}, success: #{result.success?}")
93
+ unless result.success?
94
+ debug("gh_json stderr: #{result.stderr.strip.inspect}")
95
+ debug("gh_json stdout: #{result.stdout.strip.inspect}")
96
+ return nil
97
+ end
98
+
99
+ body = result.stdout.strip
100
+ debug("gh_json response body (first 500 chars): #{body[0, 500].inspect}")
101
+ body.empty? ? {} : JSON.parse(body)
102
+ end
103
+
104
+ def debug(message)
105
+ context.ui.debug(message) if context.debug?
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class CLI
5
+ COMMANDS = {
6
+ "prepare" => Commands::Prepare,
7
+ "build" => Commands::Build,
8
+ "github" => Commands::Github,
9
+ "run" => Commands::Run
10
+ }.freeze
11
+
12
+ attr_reader :argv, :env
13
+
14
+ def initialize(argv, env: ENV)
15
+ @argv = argv
16
+ @env = env
17
+ end
18
+
19
+ def run
20
+ options = Options.parse(argv)
21
+ context = Context.new(options, env:)
22
+ subscribe(context)
23
+ command = COMMANDS.fetch(options.fetch(:command)) { raise Error, usage }
24
+ command.new(context).call
25
+ rescue Error => e
26
+ UI.new.error(e.message)
27
+ exit 1
28
+ end
29
+
30
+ private
31
+
32
+ def subscribe(context)
33
+ context.events.subscribe(:checkpoint) do |_name, payload|
34
+ next unless payload[:kind] == :wait_for_merge
35
+
36
+ context.ui.info("Checkpoint: waiting for PR merge (#{payload[:pr_url]})")
37
+ end
38
+ end
39
+
40
+ def usage
41
+ [
42
+ "Usage: puma-release [options] [command]",
43
+ "",
44
+ "Commands:",
45
+ " prepare open the release PR and draft release",
46
+ " build tag the release and build both gem artifacts",
47
+ " github publish the GitHub release and upload assets",
48
+ " run detect the next step and run it (default)"
49
+ ].join("\n")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ module Commands
5
+ class Build
6
+ attr_reader :context, :git_repo, :repo_files, :github
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ @git_repo = GitRepo.new(context)
11
+ @repo_files = RepoFiles.new(context)
12
+ @github = GitHubClient.new(context)
13
+ end
14
+
15
+ def call
16
+ context.check_dependencies!("git", "gh", "bundle")
17
+ context.announce_live_mode!
18
+ context.ensure_release_writes_allowed!
19
+ git_repo.ensure_clean_base!
20
+ version = repo_files.current_version
21
+ tag = git_repo.release_tag(version)
22
+ context.ui.info("Ensuring tag #{tag} points at HEAD and is pushed...")
23
+ git_repo.ensure_release_tag_pushed!(tag)
24
+ sync_release_target_to_tag(tag)
25
+ sync_release_title(tag, version)
26
+ context.ui.info("Building MRI gem...")
27
+ context.shell.run("bundle", "exec", "rake", "build")
28
+ context.ui.info("Built: pkg/puma-#{version}.gem")
29
+ jruby_built = BuildSupport.new(context).build_jruby_gem(version)
30
+ manual_jruby_instructions unless jruby_built
31
+ context.events.publish(:checkpoint, kind: :wait_for_rubygems, version:, tag:)
32
+ context.ui.info("STOP: push both gems to RubyGems, then rerun puma-release.")
33
+ :wait_for_rubygems
34
+ end
35
+
36
+ private
37
+
38
+ def sync_release_target_to_tag(tag)
39
+ release = github.release(tag)
40
+ return unless release
41
+
42
+ tag_sha = git_repo.local_tag_sha(tag)
43
+ raise Error, "Local tag #{tag} is missing." if tag_sha.empty?
44
+ return if release.fetch("targetCommitish", "") == tag_sha
45
+
46
+ context.ui.info("Updating release target for #{tag} to #{tag_sha[0, 12]}...")
47
+ github.edit_release_target(tag, tag_sha)
48
+ end
49
+
50
+ def sync_release_title(tag, version)
51
+ release = github.release(tag)
52
+ return unless release
53
+
54
+ title = repo_files.release_name(version)
55
+ return if release.fetch("name", "") == title
56
+
57
+ context.ui.info("Updating release title for #{tag} to #{title.inspect}...")
58
+ github.edit_release_title(tag, title)
59
+ end
60
+
61
+ def manual_jruby_instructions
62
+ context.ui.warn("JRuby gem was not built automatically.")
63
+ puts "To build it manually, switch to JRuby and run:"
64
+ puts " bundle exec rake java gem"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ module Commands
5
+ class Github
6
+ attr_reader :context, :git_repo, :repo_files, :github
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ @git_repo = GitRepo.new(context)
11
+ @repo_files = RepoFiles.new(context)
12
+ @github = GitHubClient.new(context)
13
+ end
14
+
15
+ def call
16
+ context.check_dependencies!("git", "gh")
17
+ context.announce_live_mode!
18
+ context.ensure_release_writes_allowed!
19
+ git_repo.ensure_clean_base!
20
+ version = repo_files.current_version
21
+ tag = git_repo.release_tag(version)
22
+ proposal_tag = git_repo.proposal_tag(version)
23
+ artifact_paths = artifacts(version)
24
+ body = repo_files.extract_history_section(version) || raise(Error, "Could not find section for #{version} in #{context.history_file}")
25
+ title = repo_files.release_name(version)
26
+ git_repo.ensure_release_tag_pushed!(tag)
27
+ tag_sha = git_repo.local_tag_sha(tag)
28
+ raise Error, "Local tag #{tag} is missing." if tag_sha.empty?
29
+
30
+ release = ensure_release_for_final_tag(tag, proposal_tag, body, title, tag_sha)
31
+ release = github.edit_release_target(tag, tag_sha) if release.fetch("targetCommitish", "") != tag_sha
32
+ release = github.edit_release_title(tag, title) if release.fetch("name", "") != title
33
+ release = github.edit_release_notes(tag, body) if release.fetch("body", "") != body
34
+ github.upload_release_assets(tag, *artifact_paths)
35
+ release = github.publish_release(tag) if release.fetch("isDraft", false)
36
+ cleanup_proposal_release!(proposal_tag)
37
+ context.events.publish(:release_published, tag:, url: release.fetch("url"))
38
+ context.ui.info("GitHub release published: #{release.fetch("url")}")
39
+ :complete
40
+ end
41
+
42
+ private
43
+
44
+ def ensure_release_for_final_tag(tag, proposal_tag, body, title, tag_sha)
45
+ release = github.release(tag)
46
+ proposal_release = github.release(proposal_tag)
47
+ raise Error, "Found both #{tag} and #{proposal_tag} releases. Clean up the proposal release before continuing." if release && proposal_release
48
+ return release if release
49
+ return promote_proposal_release!(proposal_tag, tag, tag_sha) if proposal_release
50
+
51
+ github.create_release(tag, body, title:, draft: true)
52
+ end
53
+
54
+ def promote_proposal_release!(proposal_tag, tag, tag_sha)
55
+ context.ui.info("Promoting draft release from #{proposal_tag} to #{tag}...")
56
+ github.retag_release(proposal_tag, tag, target: tag_sha)
57
+ end
58
+
59
+ def cleanup_proposal_release!(proposal_tag)
60
+ github.delete_release(proposal_tag, allow_failure: true) if github.release(proposal_tag)
61
+ github.delete_tag_ref(proposal_tag, allow_failure: true) unless git_repo.remote_tag_sha(proposal_tag).empty?
62
+ end
63
+
64
+ def artifacts(version)
65
+ paths = [
66
+ context.repo_dir.join("pkg", "puma-#{version}.gem"),
67
+ context.repo_dir.join("pkg", "puma-#{version}-java.gem")
68
+ ]
69
+ missing = paths.reject(&:file?)
70
+ raise Error, "Missing release artifact(s): #{missing.join(" ")}" unless missing.empty?
71
+
72
+ paths.map(&:to_s)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ module Commands
5
+ class Prepare
6
+ attr_reader :context, :git_repo, :repo_files, :github, :contributors
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ @git_repo = GitRepo.new(context)
11
+ @repo_files = RepoFiles.new(context)
12
+ @github = GitHubClient.new(context)
13
+ @contributors = ContributorResolver.new(context, git_repo:, github:)
14
+ end
15
+
16
+ def call
17
+ context.check_dependencies!("git", "gh", context.agent_binary)
18
+ context.announce_live_mode!
19
+ context.ensure_release_writes_allowed!
20
+ git_repo.ensure_clean_base!
21
+ ensure_green_ci!
22
+
23
+ last_tag = git_repo.last_tag
24
+ context.ui.info("Last release tag: #{last_tag}")
25
+ release_range = ReleaseRange.new(context, last_tag)
26
+ recommendation = VersionRecommender.new(context, release_range).call
27
+ bump_type = recommendation.fetch("bump_type")
28
+ current_version = repo_files.current_version
29
+ new_version = git_repo.bump_version(current_version, bump_type)
30
+ context.ui.info("Version bump: #{current_version} -> #{new_version}")
31
+ show_version_recommendation(recommendation)
32
+
33
+ earner = show_codename_earner(last_tag, bump_type)
34
+ changelog = prepare_changelog(release_range, new_version, last_tag)
35
+ context.ui.info("Generating link references...")
36
+ refs = LinkReferenceBuilder.new(context).build(changelog)
37
+ repo_files.prepend_history_section!(new_version, changelog, refs)
38
+ repo_files.update_version!(new_version, bump_type, codename: context.codename)
39
+ upgrade_guide_path = write_upgrade_guide(release_range, new_version, recommendation, bump_type)
40
+ security_file = update_security_policy(new_version, bump_type)
41
+
42
+ branch = "release-v#{new_version}"
43
+ git_repo.checkout_release_branch!(branch)
44
+ git_repo.commit_release!(new_version, extra_files: [*Array(upgrade_guide_path), *Array(security_file)])
45
+ git_repo.push_branch!(branch)
46
+
47
+ compare_url = "https://github.com/#{context.metadata_repo}/compare/#{last_tag}...#{git_repo.head_sha}"
48
+ pr_url = github.create_release_pr("Release v#{new_version}", branch, body: compare_url)
49
+ github.comment_on_pr(pr_url, pr_comment(recommendation, earner))
50
+ release = ensure_draft_release(new_version, branch)
51
+ release_url = release.fetch("url")
52
+ github.update_pr_body(pr_url, "#{compare_url}\n\n#{release_url}")
53
+ context.events.publish(:checkpoint, kind: :wait_for_merge, pr_url:, release_url:, branch:)
54
+
55
+ context.ui.info("Release PR created: #{pr_url}")
56
+ context.ui.info("Draft GitHub release ready: #{release.fetch("url")}")
57
+ context.ui.warn(waiting_on_codename_message(earner)) if earner
58
+ context.ui.info("STOP: review and merge the PR, then rerun puma-release.")
59
+ :wait_for_merge
60
+ end
61
+
62
+ private
63
+
64
+ def ensure_green_ci!
65
+ return context.ui.warn("Skipping CI check because --skip-ci-check was set.") if context.skip_ci_check?
66
+
67
+ context.ui.info("Checking CI status for HEAD...")
68
+ ci_checker.ensure_green!(git_repo.head_sha)
69
+ end
70
+
71
+ def ci_checker = CiChecker.new(context)
72
+
73
+ def show_codename_earner(last_tag, bump_type)
74
+ return nil if bump_type == "patch"
75
+ return nil if context.codename
76
+
77
+ context.ui.info("Top contributors since #{last_tag}:")
78
+ git_repo.top_contributors_since(last_tag).first(5).each { |line| puts line }
79
+ earner = contributors.codename_earner(last_tag)
80
+ return nil unless earner
81
+
82
+ label = earner.fetch(:name)
83
+ label += " (@#{earner[:login]})" if earner[:login]
84
+ context.ui.info("Codename earner: #{label}")
85
+ earner
86
+ end
87
+
88
+ def show_version_recommendation(recommendation)
89
+ context.ui.info("Version bump recommendation:")
90
+ puts recommendation.fetch("reasoning_markdown")
91
+
92
+ breaking_changes = recommendation.fetch("breaking_changes", [])
93
+ return if breaking_changes.empty?
94
+
95
+ context.ui.warn("Potential breaking changes:")
96
+ breaking_changes.each { |item| puts "- #{item}" }
97
+ end
98
+
99
+ def update_security_policy(new_version, bump_type)
100
+ return nil unless bump_type == "major"
101
+
102
+ repo_files.update_security!(new_version)
103
+ context.security_file
104
+ end
105
+
106
+ def write_upgrade_guide(release_range, new_version, recommendation, bump_type)
107
+ return nil unless bump_type == "major"
108
+
109
+ UpgradeGuideWriter.new(
110
+ context,
111
+ release_range,
112
+ new_version:,
113
+ breaking_changes: recommendation.fetch("breaking_changes", []),
114
+ codename: context.codename
115
+ ).call
116
+ end
117
+
118
+ def prepare_changelog(release_range, new_version, last_tag)
119
+ tag = git_repo.proposal_tag(new_version)
120
+ git_repo.create_signed_tag!(tag, message: "Temporary changelog tag for #{tag}")
121
+ ChangelogGenerator.new(context, release_range, new_tag: tag, last_tag:).call
122
+ ensure
123
+ git_repo.delete_local_tag!(tag, allow_failure: true) if tag
124
+ end
125
+
126
+ def ensure_draft_release(version, branch)
127
+ tag = git_repo.proposal_tag(version)
128
+ body = release_body(version)
129
+ title = repo_files.release_name(version)
130
+ release = github.release(tag)
131
+ release ||= github.create_release(tag, body, title:, draft: true, target: branch)
132
+ release = github.edit_release_target(tag, branch) if release.fetch("targetCommitish", "") != branch
133
+ release = github.edit_release_title(tag, title) if release.fetch("name", "") != title
134
+ (release.fetch("body", "") == body) ? release : github.edit_release_notes(tag, body)
135
+ end
136
+
137
+ def pr_comment(recommendation, earner)
138
+ lines = [
139
+ context.comment_attribution(recommendation.fetch("model_name", context.comment_author_model_name)),
140
+ "",
141
+ "## Version bump recommendation",
142
+ "",
143
+ "Recommended bump: **#{recommendation.fetch("bump_type")}**",
144
+ "",
145
+ recommendation.fetch("reasoning_markdown")
146
+ ]
147
+
148
+ breaking_changes = recommendation.fetch("breaking_changes", [])
149
+ if breaking_changes.any?
150
+ lines += ["", "## Potential breaking changes", ""]
151
+ lines += breaking_changes.map { |item| "- #{item}" }
152
+ else
153
+ lines += ["", "## Potential breaking changes", "", "_None identified._"]
154
+ end
155
+
156
+ return lines.join("\n") unless earner
157
+
158
+ [*lines, "", "## Codename", "", codename_message(earner)].join("\n")
159
+ end
160
+
161
+ def codename_message(earner)
162
+ return "@#{earner.fetch(:login)} earned the codename for this release. Please propose a codename!" if earner[:login]
163
+
164
+ "#{earner.fetch(:name)} earned the codename for this release. Please propose a codename!"
165
+ end
166
+
167
+ def waiting_on_codename_message(earner)
168
+ return "Waiting on @#{earner.fetch(:login)} for a codename before merging." if earner[:login]
169
+
170
+ "Waiting on #{earner.fetch(:name)} for a codename before merging."
171
+ end
172
+
173
+ def release_body(version)
174
+ repo_files.extract_history_section(version) || raise(Error, "Could not find section for #{version} in #{context.history_file}")
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ module Commands
5
+ class Run
6
+ attr_reader :context
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ end
11
+
12
+ def call
13
+ step = stage_detector.next_step
14
+ return wait_for_merge if step == :wait_for_merge
15
+ return complete if step == :complete
16
+ return run_step(step) if confirm_step(step)
17
+
18
+ :aborted
19
+ end
20
+
21
+ private
22
+
23
+ def stage_detector = StageDetector.new(context)
24
+
25
+ def confirm_step(step)
26
+ return true if context.yes?
27
+
28
+ context.ui.confirm("Detected next step: #{step}. Continue?")
29
+ end
30
+
31
+ def run_step(step)
32
+ case step
33
+ when :prepare then Prepare.new(context).call
34
+ when :build then Build.new(context).call
35
+ when :github then Github.new(context).call
36
+ else raise Error, "Unknown step: #{step}"
37
+ end
38
+ end
39
+
40
+ def complete
41
+ context.ui.info("The current release is already complete. No action needed.")
42
+ :complete
43
+ end
44
+
45
+ def wait_for_merge
46
+ context.ui.info("A release PR is already in flight. Merge it, update local #{context.base_branch}, and rerun puma-release.")
47
+ :wait_for_merge
48
+ end
49
+ end
50
+ end
51
+ end