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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/exe/release_ceremony +11 -0
- data/lib/release_ceremony/changelog.rb +91 -0
- data/lib/release_ceremony/cli.rb +31 -0
- data/lib/release_ceremony/command.rb +28 -0
- data/lib/release_ceremony/error.rb +5 -0
- data/lib/release_ceremony/prepare.rb +118 -0
- data/lib/release_ceremony/project.rb +173 -0
- data/lib/release_ceremony/publish.rb +174 -0
- data/lib/release_ceremony/pull_request_finder.rb +80 -0
- data/lib/release_ceremony/runner.rb +76 -0
- data/lib/release_ceremony/semver.rb +50 -0
- data/lib/release_ceremony/tag_inspector.rb +76 -0
- data/lib/release_ceremony/transactional_writer.rb +100 -0
- data/lib/release_ceremony/version.rb +5 -0
- data/lib/release_ceremony/workflow_finder.rb +86 -0
- data/lib/release_ceremony.rb +16 -0
- metadata +67 -0
|
@@ -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,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'
|