release_ceremony 1.0.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseCeremony
4
+ # Publishes a prepared, merged release: validates the merge commit, tags
5
+ # it, pushes the tag, watches the tag-triggered release workflow, and
6
+ # confirms the gem appears on RubyGems.
7
+ class Publish
8
+ extend Command
9
+
10
+ USAGE = 'Usage: release_ceremony publish VERSION'
11
+ HELP = <<~TEXT.freeze
12
+ #{USAGE}
13
+
14
+ Validate and publish the merged release-vVERSION pull request by tagging
15
+ its merge commit and monitoring the release workflow.
16
+ TEXT
17
+
18
+ def initialize(version:, runner:, project: nil)
19
+ @version = version
20
+ @runner = runner
21
+ @project = project || Project.discover(runner: runner)
22
+ end
23
+
24
+ def call
25
+ runner.run('git', 'fetch', '--no-tags', project.remote, project.default_branch)
26
+ pull_request = merged_pull_request
27
+ sha = pull_request.sha
28
+ validate_release_commit!(sha)
29
+ tags = tag_state
30
+ validate_existing_tags!(tags, sha)
31
+ print_summary(pull_request, tags)
32
+ confirm!
33
+ publish_tag(tags, sha)
34
+ monitor_release(sha)
35
+ true
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :project, :runner, :version
41
+
42
+ def merged_pull_request
43
+ PullRequestFinder.new(version: version, runner: runner, project: project).call
44
+ end
45
+
46
+ def remote_branch
47
+ "#{project.remote}/#{project.default_branch}"
48
+ end
49
+
50
+ def validate_release_commit!(sha)
51
+ validate_ancestry!(sha)
52
+ validate_version_file!(runner.capture('git', 'show', "#{sha}:#{project.version_file}"))
53
+ validate_changelog!(runner.capture('git', 'show', "#{sha}:#{project.changelog_file}"))
54
+ end
55
+
56
+ def validate_ancestry!(sha)
57
+ runner.run('git', 'merge-base', '--is-ancestor', sha, remote_branch)
58
+ rescue Error
59
+ raise Error, "Merge commit #{sha} is not an ancestor of #{remote_branch}"
60
+ end
61
+
62
+ def validate_version_file!(contents)
63
+ assignments = contents.lines.grep(/^[ \t]*VERSION[ \t]*=/)
64
+ expected = /^[ \t]*VERSION = '#{Regexp.escape(version.to_s)}'[ \t]*$/
65
+ return if assignments.one? && assignments.first.match?(expected)
66
+
67
+ raise Error, "#{project.version_file} at the merge commit does not assign VERSION = '#{version}' exactly"
68
+ end
69
+
70
+ def validate_changelog!(contents)
71
+ return if changelog_heading?(contents) && changelog_link?(contents)
72
+
73
+ raise Error,
74
+ "#{project.changelog_file} at the merge commit lacks the exact #{version} heading or comparison link"
75
+ end
76
+
77
+ def changelog_heading?(contents)
78
+ heading = /^## \[#{Regexp.escape(version.to_s)}\] - \d{4}-\d{2}-\d{2}[ \t]*$/
79
+ contents.lines.grep(heading).one?
80
+ end
81
+
82
+ def changelog_link?(contents)
83
+ link = /^\[#{Regexp.escape(version.to_s)}\]:[ ]#{Regexp.escape(project.compare_base)}
84
+ v\d+\.\d+\.\d+\.\.\.v#{Regexp.escape(version.to_s)}[ \t]*$/x
85
+ contents.lines.grep(link).one?
86
+ end
87
+
88
+ def tag_state
89
+ TagInspector.new(version: version, runner: runner, remote: project.remote).call
90
+ end
91
+
92
+ def validate_existing_tags!(tags, sha)
93
+ validate_existing_tag!('Local', tags.local_sha, sha)
94
+ validate_existing_tag!('Remote', tags.remote_sha, sha)
95
+ end
96
+
97
+ def validate_existing_tag!(location, existing_sha, expected_sha)
98
+ return unless existing_sha
99
+ return if existing_sha == expected_sha
100
+
101
+ raise Error, "#{location} tag #{version.tag} points to #{existing_sha}, not merge commit #{expected_sha}"
102
+ end
103
+
104
+ def print_summary(pull_request, tags)
105
+ summary_lines(pull_request, tags).each { |line| runner.puts(line) }
106
+ end
107
+
108
+ def summary_lines(pull_request, tags)
109
+ [
110
+ "Merged pull request: ##{pull_request.number} #{pull_request.url}",
111
+ "Merge commit: #{pull_request.sha}",
112
+ "Tag: #{version.tag}",
113
+ "Local tag: #{tag_description(tags.local_sha)}",
114
+ "Remote tag: #{tag_description(tags.remote_sha)}"
115
+ ]
116
+ end
117
+
118
+ def tag_description(sha)
119
+ sha ? "exists at #{sha}" : 'absent'
120
+ end
121
+
122
+ def confirm!
123
+ expected = "publish #{version.tag}"
124
+ runner.print("Type '#{expected}' to continue: ")
125
+ return if runner.gets&.chomp == expected
126
+
127
+ raise Error, 'Publication cancelled; no tag was created or pushed'
128
+ end
129
+
130
+ def publish_tag(tags, sha)
131
+ return if tags.remote_sha
132
+
133
+ runner.run('git', 'tag', version.tag, sha) unless tags.local_sha
134
+ runner.run('git', 'push', project.remote, version.tag)
135
+ end
136
+
137
+ def monitor_release(sha)
138
+ run_id = find_workflow_run(sha)
139
+ runner.puts('The release uses a protected environment; approve it in GitHub if prompted.')
140
+ open_workflow_run(run_id)
141
+ runner.run('gh', 'run', 'watch', run_id.to_s, '--exit-status')
142
+ verify_published_gem!
143
+ end
144
+
145
+ def open_workflow_run(run_id)
146
+ runner.run('gh', 'run', 'view', run_id.to_s, '--web')
147
+ rescue Error => e
148
+ message = [
149
+ "Could not open run #{run_id} in a browser: #{e.message}.",
150
+ "Visit #{project.actions_run_url(run_id)} or run gh run view #{run_id} --web."
151
+ ].join(' ')
152
+ runner.warn(message)
153
+ end
154
+
155
+ def find_workflow_run(sha)
156
+ WorkflowFinder.new(version: version, runner: runner, sha: sha, workflow_file: project.workflow_file).call
157
+ end
158
+
159
+ def verify_published_gem!
160
+ unless published_versions.include?(version.to_s)
161
+ raise Error, "RubyGems does not list #{project.gem_name} version #{version}"
162
+ end
163
+
164
+ runner.puts("Published #{project.gem_name} #{version} successfully.")
165
+ end
166
+
167
+ def published_versions
168
+ output = runner.capture('gem', 'list', '--remote', '--exact', project.gem_name, '--all')
169
+ output.lines
170
+ .filter_map { |line| line[/\A#{Regexp.escape(project.gem_name)} \(([^)]+)\)/, 1] }
171
+ .flat_map { |list| list.split(',').map(&:strip) }
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ReleaseCeremony
6
+ # Finds the single merged pull request for the release branch and extracts
7
+ # its merge commit, refusing ambiguous or malformed data.
8
+ class PullRequestFinder
9
+ PR_LIMIT = '100'
10
+ PullRequest = Struct.new(:number, :url, :sha, keyword_init: true)
11
+
12
+ def initialize(version:, runner:, project:)
13
+ @version = version
14
+ @runner = runner
15
+ @project = project
16
+ end
17
+
18
+ def call
19
+ pull_requests = parse_json(runner.capture(*command))
20
+ ensure_one_pull_request!(pull_requests)
21
+ extract_pull_request(pull_requests.first)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :project, :runner, :version
27
+
28
+ def command
29
+ [
30
+ 'gh', 'pr', 'list', '--state', 'merged', '--base', project.default_branch, '--head', "release-v#{version}",
31
+ '--json', 'number,url,mergeCommit,mergedAt', '--limit', PR_LIMIT
32
+ ]
33
+ end
34
+
35
+ def ensure_one_pull_request!(pull_requests)
36
+ raise Error, 'Malformed merged pull request data: expected a JSON array' unless pull_requests.is_a?(Array)
37
+ return if pull_requests.one?
38
+
39
+ raise Error, "Expected exactly one merged release-v#{version} pull request; found #{pull_requests.length}"
40
+ end
41
+
42
+ def extract_pull_request(pull_request)
43
+ attributes = pull_request_attributes(pull_request)
44
+ unless attributes
45
+ raise Error, 'Merged pull request data is missing a number, URL, merge time, or merge commit SHA'
46
+ end
47
+
48
+ PullRequest.new(number: attributes.fetch(0), url: attributes.fetch(1), sha: attributes.fetch(3))
49
+ end
50
+
51
+ def pull_request_attributes(pull_request)
52
+ return unless pull_request.is_a?(Hash)
53
+
54
+ merge_commit = pull_request['mergeCommit']
55
+ sha = merge_commit['oid'] if merge_commit.is_a?(Hash)
56
+ attributes = [
57
+ pull_request['number'], pull_request['url'], pull_request['mergedAt'], sha
58
+ ]
59
+ attributes if valid_pull_request_attributes?(*attributes)
60
+ end
61
+
62
+ def valid_pull_request_attributes?(number, url, merged_at, sha)
63
+ number.is_a?(Integer) && number.positive? && present?(url) && present?(merged_at) && commit_sha?(sha)
64
+ end
65
+
66
+ def parse_json(output)
67
+ JSON.parse(output)
68
+ rescue JSON::ParserError => e
69
+ raise Error, "Malformed merged pull request JSON: #{e.message}"
70
+ end
71
+
72
+ def present?(value)
73
+ value.is_a?(String) && !value.empty?
74
+ end
75
+
76
+ def commit_sha?(value)
77
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module ReleaseCeremony
7
+ # Runs external commands without a shell and mediates all console
8
+ # interaction, so command execution and I/O can be faked in tests.
9
+ class Runner
10
+ def initialize(stdout: $stdout, stderr: $stderr, stdin: $stdin, sleeper: Kernel)
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @stdin = stdin
14
+ @sleeper = sleeper
15
+ end
16
+
17
+ def capture(*command)
18
+ validate_command!(command)
19
+ captured_stdout, captured_stderr, status = Open3.capture3(*command)
20
+ return captured_stdout if status.success?
21
+
22
+ raise_command_error(command, captured_stderr)
23
+ rescue SystemCallError => e
24
+ raise_spawn_error(command, e.message)
25
+ end
26
+
27
+ def run(*command)
28
+ validate_command!(command)
29
+ result = system(*command)
30
+ return true if result
31
+
32
+ result.nil? ? raise_spawn_error(command) : raise_command_error(command)
33
+ rescue SystemCallError => e
34
+ raise_spawn_error(command, e.message)
35
+ end
36
+
37
+ def puts(message = '')
38
+ @stdout.puts(message)
39
+ end
40
+
41
+ def print(message)
42
+ @stdout.print(message)
43
+ end
44
+
45
+ def warn(message)
46
+ @stderr.puts(message)
47
+ end
48
+
49
+ def gets
50
+ @stdin.gets
51
+ end
52
+
53
+ def sleep(duration)
54
+ @sleeper.sleep(duration)
55
+ end
56
+
57
+ private
58
+
59
+ def validate_command!(command)
60
+ return if command.length > 1 && command.all?(String)
61
+
62
+ raise Error, 'Command must be provided as an executable and separate arguments'
63
+ end
64
+
65
+ def raise_command_error(command, captured_stderr = nil)
66
+ message = "Command failed: #{Shellwords.join(command.map(&:to_s))}"
67
+ message = "#{message}\n#{captured_stderr.strip}" unless captured_stderr.to_s.strip.empty?
68
+ raise Error, message
69
+ end
70
+
71
+ def raise_spawn_error(command, details = 'Executable was not found')
72
+ message = "Command could not be executed: #{Shellwords.join(command.map(&:to_s))}"
73
+ raise Error, "#{message}\n#{details}"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems/version'
4
+
5
+ module ReleaseCeremony
6
+ # A strict X.Y.Z release version: no prereleases, no leading zeroes.
7
+ class Semver
8
+ include Comparable
9
+
10
+ PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
11
+ ERROR_MESSAGE = 'Version must use the X.Y.Z format'
12
+
13
+ class << self
14
+ def parse(value)
15
+ raise Error, ERROR_MESSAGE unless value.is_a?(String) && PATTERN.match?(value)
16
+
17
+ new(value)
18
+ end
19
+
20
+ private :new
21
+ end
22
+
23
+ def initialize(value)
24
+ @value = value
25
+ @gem_version = Gem::Version.new(value)
26
+ end
27
+
28
+ def <=>(other)
29
+ return unless other.is_a?(self.class)
30
+
31
+ gem_version <=> other.gem_version
32
+ end
33
+
34
+ def to_s
35
+ value
36
+ end
37
+
38
+ def tag
39
+ "v#{value}"
40
+ end
41
+
42
+ protected
43
+
44
+ attr_reader :gem_version
45
+
46
+ private
47
+
48
+ attr_reader :value
49
+ end
50
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseCeremony
4
+ # Reports where the release tag currently points, locally and on the
5
+ # remote, refusing to guess when the tag data looks unexpected.
6
+ class TagInspector
7
+ TagState = Struct.new(:local_sha, :remote_sha, keyword_init: true)
8
+
9
+ def initialize(version:, runner:, remote:)
10
+ @version = version
11
+ @runner = runner
12
+ @remote = remote
13
+ end
14
+
15
+ def call
16
+ TagState.new(local_sha: local_tag_sha, remote_sha: remote_tag_sha)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :remote, :runner, :version
22
+
23
+ def local_tag_sha
24
+ reference = "refs/tags/#{version.tag}"
25
+ return unless local_tag_exists?(reference)
26
+
27
+ resolve_local_tag(reference)
28
+ end
29
+
30
+ def local_tag_exists?(reference)
31
+ output = runner.capture('git', 'for-each-ref', '--format=%(refname)', reference)
32
+ return false if output.empty?
33
+ raise Error, "Unexpected local tag data for #{version.tag}" unless output.lines.map(&:strip) == [reference]
34
+
35
+ true
36
+ end
37
+
38
+ def resolve_local_tag(reference)
39
+ sha = runner.capture('git', 'rev-parse', '--verify', "#{reference}^{commit}").strip
40
+ raise Error, "Local tag #{version.tag} does not resolve to a commit" unless commit_sha?(sha)
41
+
42
+ sha
43
+ end
44
+
45
+ def remote_tag_sha
46
+ reference = "refs/tags/#{version.tag}"
47
+ output = runner.capture('git', 'ls-remote', '--tags', remote, reference, "#{reference}^{}")
48
+ return if output.empty?
49
+
50
+ entries = parse_remote_tag_entries(output, reference)
51
+ entries.fetch("#{reference}^{}", entries[reference])
52
+ end
53
+
54
+ def parse_remote_tag_entries(output, reference)
55
+ pairs = output.lines.map { |line| line.strip.split(/\s+/, 2) }
56
+ raise Error, "Unexpected remote tag data for #{version.tag}" unless valid_remote_tag_pairs?(pairs, reference)
57
+
58
+ pairs.to_h { |sha, name| [name, sha] }
59
+ end
60
+
61
+ def valid_remote_tag_pairs?(pairs, reference)
62
+ names = pairs.map(&:last)
63
+ unique = names.uniq.length == pairs.length
64
+ pairs.all? { |pair| valid_remote_tag_pair?(pair, reference) } && unique && names.include?(reference)
65
+ end
66
+
67
+ def valid_remote_tag_pair?(pair, reference)
68
+ sha, name = pair
69
+ commit_sha?(sha) && [reference, "#{reference}^{}"].include?(name)
70
+ end
71
+
72
+ def commit_sha?(value)
73
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module ReleaseCeremony
7
+ # Replaces a set of files atomically: every replacement and a backup of
8
+ # every original are staged as sibling temporary files first, so a failure
9
+ # while installing can restore the originals.
10
+ class TransactionalWriter
11
+ Artifact = Struct.new(:target, :replacement, :backup, keyword_init: true)
12
+
13
+ def initialize(renamer: File.method(:rename))
14
+ @renamer = renamer
15
+ end
16
+
17
+ def write(changes)
18
+ artifacts = stage_changes(changes)
19
+ install(artifacts)
20
+ true
21
+ ensure
22
+ cleanup(artifacts || [])
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :renamer
28
+
29
+ def stage_changes(changes)
30
+ artifacts = {}
31
+ changes.each { |target, contents| artifacts[target] = stage_change(target, contents) }
32
+ artifacts.values
33
+ rescue SystemCallError, IOError => e
34
+ cleanup(artifacts.values)
35
+ raise Error, "Could not stage release files: #{e.message}"
36
+ end
37
+
38
+ def stage_change(target, contents)
39
+ mode = File.stat(target).mode & 0o7777
40
+ replacement = stage_file(target, contents, mode)
41
+ backup = stage_file(target, File.binread(target), mode)
42
+ Artifact.new(target: target, replacement: replacement, backup: backup)
43
+ rescue SystemCallError, IOError
44
+ cleanup_tempfiles([replacement, backup].compact)
45
+ raise
46
+ end
47
+
48
+ def stage_file(target, contents, mode)
49
+ tempfile = Tempfile.new([".#{File.basename(target)}.release-", '.tmp'], File.dirname(target))
50
+ tempfile.binmode
51
+ tempfile.write(contents)
52
+ tempfile.flush
53
+ tempfile.close
54
+ File.chmod(mode, tempfile.path)
55
+ tempfile
56
+ rescue SystemCallError, IOError
57
+ cleanup_tempfiles([tempfile].compact)
58
+ raise
59
+ end
60
+
61
+ def install(artifacts)
62
+ artifacts.each { |artifact| renamer.call(artifact.replacement.path, artifact.target) }
63
+ rescue SystemCallError, IOError => e
64
+ raise installation_error(e, rollback(artifacts))
65
+ end
66
+
67
+ def rollback(artifacts)
68
+ artifacts.filter_map do |artifact|
69
+ renamer.call(artifact.backup.path, artifact.target)
70
+ nil
71
+ rescue SystemCallError, IOError => e
72
+ "#{artifact.target}: #{e.message}"
73
+ end
74
+ end
75
+
76
+ def installation_error(cause, rollback_errors)
77
+ message = "Could not install release files: #{cause.message}"
78
+ if rollback_errors.empty?
79
+ Error.new("#{message}; original release files were restored")
80
+ else
81
+ details = rollback_errors.join(', ')
82
+ Error.new("#{message}; rollback failed for #{details}; release files may be inconsistent")
83
+ end
84
+ end
85
+
86
+ def cleanup(artifacts)
87
+ tempfiles = artifacts.flat_map { |artifact| [artifact.replacement, artifact.backup] }
88
+ cleanup_tempfiles(tempfiles)
89
+ end
90
+
91
+ def cleanup_tempfiles(tempfiles)
92
+ tempfiles.each do |tempfile|
93
+ tempfile.close
94
+ FileUtils.rm_f(tempfile.path)
95
+ rescue SystemCallError, IOError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseCeremony
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module ReleaseCeremony
7
+ # Polls GitHub Actions for the workflow run triggered by pushing the
8
+ # release tag, returning its run ID once it appears.
9
+ class WorkflowFinder
10
+ POLL_ATTEMPTS = 12
11
+ POLL_INTERVAL = 5
12
+
13
+ def initialize(version:, runner:, sha:, workflow_file:)
14
+ @version = version
15
+ @runner = runner
16
+ @sha = sha
17
+ @workflow_file = workflow_file
18
+ end
19
+
20
+ def call
21
+ POLL_ATTEMPTS.times do
22
+ run_id = workflow_run_id(runner.capture(*command))
23
+ return run_id if run_id
24
+
25
+ runner.sleep(POLL_INTERVAL)
26
+ end
27
+ raise Error, "Release workflow did not appear for #{sha} after 60 seconds"
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :runner, :sha, :version, :workflow_file
33
+
34
+ def command
35
+ [
36
+ 'gh', 'run', 'list', '--workflow', workflow_file, '--commit', sha, '--event', 'push', '--limit', '100',
37
+ '--json', 'databaseId,headBranch,headSha,event,createdAt'
38
+ ]
39
+ end
40
+
41
+ def workflow_run_id(output)
42
+ runs = parse_json(output)
43
+ validate_workflow_runs!(runs)
44
+ matching_runs = runs.select { |run| requested_tag_push?(run) }
45
+ return if matching_runs.empty?
46
+
47
+ matching_runs.max_by { |run| Time.iso8601(run.fetch('createdAt')) }.fetch('databaseId')
48
+ end
49
+
50
+ def parse_json(output)
51
+ JSON.parse(output)
52
+ rescue JSON::ParserError => e
53
+ raise Error, "Malformed release workflow run JSON: #{e.message}"
54
+ end
55
+
56
+ def validate_workflow_runs!(runs)
57
+ raise Error, 'Malformed release workflow run data: expected a JSON array' unless runs.is_a?(Array)
58
+ return if runs.all? { |run| valid_workflow_run?(run) }
59
+
60
+ raise Error, 'Release workflow run data is missing a databaseId or valid ref, SHA, event, or createdAt'
61
+ end
62
+
63
+ def valid_workflow_run?(run)
64
+ return false unless run.is_a?(Hash)
65
+
66
+ id = run['databaseId']
67
+ strings = %w[headBranch headSha event createdAt].map { |key| run[key] }
68
+ id.is_a?(Integer) && id.positive? && strings.all? { |value| present?(value) } && valid_time?(run['createdAt'])
69
+ end
70
+
71
+ def valid_time?(value)
72
+ Time.iso8601(value)
73
+ true
74
+ rescue ArgumentError
75
+ false
76
+ end
77
+
78
+ def requested_tag_push?(run)
79
+ run['headBranch'] == version.tag && run['headSha'] == sha && run['event'] == 'push'
80
+ end
81
+
82
+ def present?(value)
83
+ value.is_a?(String) && !value.empty?
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'release_ceremony/version'
4
+ require_relative 'release_ceremony/error'
5
+ require_relative 'release_ceremony/semver'
6
+ require_relative 'release_ceremony/runner'
7
+ require_relative 'release_ceremony/project'
8
+ require_relative 'release_ceremony/command'
9
+ require_relative 'release_ceremony/transactional_writer'
10
+ require_relative 'release_ceremony/changelog'
11
+ require_relative 'release_ceremony/prepare'
12
+ require_relative 'release_ceremony/tag_inspector'
13
+ require_relative 'release_ceremony/workflow_finder'
14
+ require_relative 'release_ceremony/pull_request_finder'
15
+ require_relative 'release_ceremony/publish'
16
+ require_relative 'release_ceremony/cli'