ruby-repo-kit 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/config/rubocop.yml +55 -0
- data/exe/ruby-repo-kit +6 -0
- data/lib/ruby_repo_kit/checks/commits.rb +84 -0
- data/lib/ruby_repo_kit/checks.rb +114 -0
- data/lib/ruby_repo_kit/cli.rb +128 -0
- data/lib/ruby_repo_kit/commands.rb +43 -0
- data/lib/ruby_repo_kit/github/client.rb +91 -0
- data/lib/ruby_repo_kit/github/configuration.rb +180 -0
- data/lib/ruby_repo_kit/github/policy.rb +52 -0
- data/lib/ruby_repo_kit/github.rb +14 -0
- data/lib/ruby_repo_kit/package.rb +100 -0
- data/lib/ruby_repo_kit/project.rb +100 -0
- data/lib/ruby_repo_kit/rake_tasks.rb +98 -0
- data/lib/ruby_repo_kit/release/artifact.rb +47 -0
- data/lib/ruby_repo_kit/release/metadata.rb +157 -0
- data/lib/ruby_repo_kit/release/preparation.rb +81 -0
- data/lib/ruby_repo_kit/release/publication.rb +146 -0
- data/lib/ruby_repo_kit/release/publisher.rb +27 -0
- data/lib/ruby_repo_kit/release/workflow.rb +182 -0
- data/lib/ruby_repo_kit/release.rb +20 -0
- data/lib/ruby_repo_kit/scaffold.rb +115 -0
- data/lib/ruby_repo_kit/specification.rb +44 -0
- data/lib/ruby_repo_kit/version.rb +5 -0
- data/lib/ruby_repo_kit.rb +10 -0
- data/templates/cli/.editorconfig.erb +12 -0
- data/templates/cli/.github/ISSUE_TEMPLATE/bug_report.yml.erb +18 -0
- data/templates/cli/.github/ISSUE_TEMPLATE/config.yml.erb +5 -0
- data/templates/cli/.github/ISSUE_TEMPLATE/feature_request.yml.erb +13 -0
- data/templates/cli/.github/PULL_REQUEST_TEMPLATE.md.erb +5 -0
- data/templates/cli/.github/dependabot.yml.erb +27 -0
- data/templates/cli/.github/workflows/ci.yml.erb +106 -0
- data/templates/cli/.github/workflows/release.yml.erb +139 -0
- data/templates/cli/.gitignore.erb +6 -0
- data/templates/cli/.rubocop.yml.erb +5 -0
- data/templates/cli/.ruby-repo.yml.erb +1 -0
- data/templates/cli/.ruby-version.erb +1 -0
- data/templates/cli/ARCHITECTURE.md.erb +25 -0
- data/templates/cli/CHANGELOG.md.erb +14 -0
- data/templates/cli/CODE_OF_CONDUCT.md.erb +14 -0
- data/templates/cli/CONTRIBUTING.md.erb +33 -0
- data/templates/cli/Gemfile.erb +17 -0
- data/templates/cli/LICENSE.txt.erb +21 -0
- data/templates/cli/README.md.erb +53 -0
- data/templates/cli/Rakefile.erb +38 -0
- data/templates/cli/SECURITY.md.erb +11 -0
- data/templates/cli/__name__.gemspec.erb +31 -0
- data/templates/cli/bin/release.erb +7 -0
- data/templates/cli/bin/setup.erb +8 -0
- data/templates/cli/docs/releasing.md.erb +42 -0
- data/templates/cli/exe/__name__.erb +6 -0
- data/templates/cli/lib/__require_path__/cli.rb.erb +50 -0
- data/templates/cli/lib/__require_path__/version.rb.erb +5 -0
- data/templates/cli/lib/__require_path__.rb.erb +4 -0
- data/templates/cli/test/cli_test.rb.erb +62 -0
- data/templates/cli/test/test_helper.rb.erb +15 -0
- metadata +252 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "rubygems/package"
|
|
5
|
+
|
|
6
|
+
module RubyRepoKit
|
|
7
|
+
module Release
|
|
8
|
+
# Records and checks existing bytes; publication never builds another gem.
|
|
9
|
+
class Artifact
|
|
10
|
+
def initialize(project:)
|
|
11
|
+
@project = project
|
|
12
|
+
@metadata = Metadata.new(project: project)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def path = @project.path("pkg/#{@project.name}-#{@metadata.version}.gem")
|
|
16
|
+
|
|
17
|
+
def record
|
|
18
|
+
artifact = path
|
|
19
|
+
digest = Digest::SHA256.file(artifact).hexdigest
|
|
20
|
+
text = File.read(@project.path(@project.changelog))
|
|
21
|
+
section = text.include?("## [#{@metadata.version}]") ? @metadata.version : "Unreleased"
|
|
22
|
+
File.write(@project.path("pkg/SHA256SUMS"), "#{digest} #{File.basename(artifact)}\n")
|
|
23
|
+
File.write(@project.path("pkg/release-notes.md"), "#{@metadata.notes(text, section).strip}\n")
|
|
24
|
+
artifact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def verify(expected: ENV.fetch("RELEASE_SHA256", nil))
|
|
28
|
+
artifact = path
|
|
29
|
+
digest = Digest::SHA256.file(artifact).hexdigest
|
|
30
|
+
manifest = File.read(@project.path("pkg/SHA256SUMS"))
|
|
31
|
+
unless manifest == "#{digest} #{File.basename(artifact)}\n" && (expected.nil? || expected == digest)
|
|
32
|
+
raise Error, "Release artifact checksum mismatch"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
package = Gem::Package.new(artifact)
|
|
36
|
+
package.verify
|
|
37
|
+
unless package.spec.name == @project.name && package.spec.version.to_s == @metadata.version
|
|
38
|
+
raise Error, "Release artifact name/version does not match the checkout"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
artifact
|
|
42
|
+
rescue Gem::Package::Error, SystemCallError => e
|
|
43
|
+
raise Error, "Cannot verify release artifact: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "rubygems/version"
|
|
5
|
+
|
|
6
|
+
module RubyRepoKit
|
|
7
|
+
module Release
|
|
8
|
+
# Parses repository-owned metadata without loading the consumer's Ruby code.
|
|
9
|
+
class Metadata
|
|
10
|
+
VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
|
|
11
|
+
VERSION_LINE = /^([ \t]*VERSION[ \t]*=[ \t]*)(["'])([^"'\r\n]+)\2((?:\.freeze)?[ \t]*(?:#.*)?)\r?$/
|
|
12
|
+
HEADING = /\A## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})( \[YANKED\])?\z/
|
|
13
|
+
Heading = Data.define(:version, :date, :yanked)
|
|
14
|
+
|
|
15
|
+
def initialize(project:, clock: SystemClock, commands: nil)
|
|
16
|
+
@project = project
|
|
17
|
+
@clock = clock
|
|
18
|
+
@commands = commands || Commands.new(project: project)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def version = version_from(File.read(@project.path(@project.version_file)))
|
|
22
|
+
|
|
23
|
+
def version_from(source)
|
|
24
|
+
version_match(source)[3].tap { |value| validate_version(value) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_version(target)
|
|
28
|
+
raise Error, "Use a stable X.Y.Z version" unless target.is_a?(String) && VERSION_PATTERN.match?(target)
|
|
29
|
+
|
|
30
|
+
target
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_branch(branch, target)
|
|
34
|
+
validate_version(target)
|
|
35
|
+
hotfix = "hotfix/#{target.split('.').first(2).join('.')}"
|
|
36
|
+
raise Error, "Release branch must be main or #{hotfix}" unless ["main", hotfix].include?(branch)
|
|
37
|
+
|
|
38
|
+
branch
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify(tag:, version: self.version, changelog: nil)
|
|
42
|
+
validate_version(version)
|
|
43
|
+
text = changelog || File.read(@project.path(@project.changelog))
|
|
44
|
+
history = validate_history(text)
|
|
45
|
+
# A branch rehearsal checks the release we would prepare, without writing it.
|
|
46
|
+
if tag.nil? && history.none? { |heading| heading.version == version }
|
|
47
|
+
changes(version,
|
|
48
|
+
source: { @project.version_file => "VERSION = \"#{version}\"\n", @project.changelog => text })
|
|
49
|
+
return version
|
|
50
|
+
end
|
|
51
|
+
raise Error, "Release tag must be v#{version}" unless tag.nil? || tag == "v#{version}"
|
|
52
|
+
raise Error, "The newest changelog release must be #{version}" unless history.first&.version == version
|
|
53
|
+
raise Error, "Cannot publish a yanked release" if history.first.yanked
|
|
54
|
+
raise Error, "Release notes for #{version} are empty" unless notes(text, version).match?(/^[-*] \S/)
|
|
55
|
+
|
|
56
|
+
check_reference(text, "Unreleased", "#{@project.url}/compare/v#{version}...HEAD")
|
|
57
|
+
version
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def verify_ref(sha: ENV.fetch("GITHUB_SHA"), version: self.version)
|
|
61
|
+
validate_version(version)
|
|
62
|
+
raise Error, "Invalid release commit" unless sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)
|
|
63
|
+
|
|
64
|
+
refs = @commands.call(["git", "for-each-ref", "--contains", sha, "--format=%(refname:short)",
|
|
65
|
+
"refs/remotes/origin"])
|
|
66
|
+
allowed = ["origin/main", "origin/hotfix/#{version.split('.').first(2).join('.')}"]
|
|
67
|
+
return true if refs.lines.map(&:strip).intersect?(allowed)
|
|
68
|
+
|
|
69
|
+
raise Error, "Release commit must belong to main or its matching hotfix branch"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def notes(changelog, target)
|
|
73
|
+
heading = /^## \[#{Regexp.escape(target)}\][^\n]*\n/
|
|
74
|
+
return "" unless changelog.match?(heading)
|
|
75
|
+
|
|
76
|
+
changelog.split(heading, 2).last.to_s.split(/^## |^\[[^\]]+\]:/, 2).first.to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def changes(target, source: nil, date: nil)
|
|
80
|
+
validate_version(target)
|
|
81
|
+
source ||= [@project.version_file, @project.changelog].to_h { |path| [path, File.read(@project.path(path))] }
|
|
82
|
+
original = source.fetch(@project.version_file)
|
|
83
|
+
current = version_from(original)
|
|
84
|
+
raise Error, "Version cannot go backwards" if Gem::Version.new(target) < Gem::Version.new(current)
|
|
85
|
+
|
|
86
|
+
text = source.fetch(@project.changelog)
|
|
87
|
+
history = validate_history(text)
|
|
88
|
+
raise Error, "Version already appears in the changelog" if history.any? { |heading| heading.version == target }
|
|
89
|
+
raise Error, "Add release notes under Unreleased first" unless notes(text, "Unreleased").match?(/^[-*] \S/)
|
|
90
|
+
|
|
91
|
+
release_date = date || @clock.now.utc.to_date
|
|
92
|
+
updated = text.sub("## [Unreleased]\n", "## [Unreleased]\n\n## [#{target}] - #{release_date.iso8601}\n")
|
|
93
|
+
link = "[Unreleased]: #{@project.url}/compare/v#{target}...HEAD"
|
|
94
|
+
updated = updated.sub(/^\[Unreleased\]:[^\n]*$/, link)
|
|
95
|
+
updated = "#{updated.rstrip}\n[#{target}]: #{@project.url}/releases/tag/v#{target}\n"
|
|
96
|
+
verify(tag: "v#{target}", version: target, changelog: updated)
|
|
97
|
+
match = version_match(original)
|
|
98
|
+
replacement = "#{match[1]}#{match[2]}#{target}#{match[2]}#{match[4]}"
|
|
99
|
+
{ @project.version_file => original.sub(VERSION_LINE) { replacement }, @project.changelog => updated }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def version_match(source)
|
|
105
|
+
assignments = source.scan(/^[ \t]*VERSION[ \t]*=/)
|
|
106
|
+
matches = source.to_enum(:scan, VERSION_LINE).map { Regexp.last_match }
|
|
107
|
+
unless assignments.length == 1 && matches.length == 1
|
|
108
|
+
raise Error, "#{@project.version_file} must contain exactly one static VERSION string assignment"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
matches.first
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_history(text)
|
|
115
|
+
sections = text.lines.map(&:chomp).grep(/^## /)
|
|
116
|
+
unless sections.first == "## [Unreleased]" && sections.count("## [Unreleased]") == 1
|
|
117
|
+
raise Error, "Expected one Unreleased section before the release history"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
history = sections.drop(1).map { |line| parse_heading(line) }
|
|
121
|
+
versions = history.map(&:version)
|
|
122
|
+
raise Error, "Duplicate changelog release" unless versions.uniq == versions
|
|
123
|
+
|
|
124
|
+
history.each_cons(2) do |newer, older|
|
|
125
|
+
unless Gem::Version.new(newer.version) > Gem::Version.new(older.version) && newer.date >= older.date
|
|
126
|
+
raise Error, "Changelog releases must be newest first by version and date"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
history.each do |heading|
|
|
130
|
+
check_reference(text, heading.version, "#{@project.url}/releases/tag/v#{heading.version}")
|
|
131
|
+
end
|
|
132
|
+
references = text.lines.grep(/^\[Unreleased\]:/)
|
|
133
|
+
raise Error, "Expected one Unreleased link reference" unless references.length == 1
|
|
134
|
+
|
|
135
|
+
history
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_heading(line)
|
|
139
|
+
match = HEADING.match(line)
|
|
140
|
+
raise Error, "Invalid changelog release heading: #{line}" unless match
|
|
141
|
+
|
|
142
|
+
validate_version(match[1])
|
|
143
|
+
date = Date.iso8601(match[2])
|
|
144
|
+
raise Error, "Release date cannot be in the future" if date > @clock.now.utc.to_date
|
|
145
|
+
|
|
146
|
+
Heading.new(match[1], date, !match[3].nil?)
|
|
147
|
+
rescue Date::Error
|
|
148
|
+
raise Error, "Invalid changelog release date"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_reference(text, name, url)
|
|
152
|
+
lines = text.lines.map(&:chomp).grep(/^\[#{Regexp.escape(name)}\]:/)
|
|
153
|
+
raise Error, "Missing or incorrect release link for #{name}" unless lines == ["[#{name}]: #{url}"]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRepoKit
|
|
4
|
+
module Release
|
|
5
|
+
# Owns local edits and recovery; the workflow owns remote PR and tag transitions.
|
|
6
|
+
class Preparation
|
|
7
|
+
def initialize(project:, commands:, metadata:, clock: SystemClock)
|
|
8
|
+
@project = project
|
|
9
|
+
@commands = commands
|
|
10
|
+
@metadata = metadata
|
|
11
|
+
@clock = clock
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resume(version, dry_run: false)
|
|
15
|
+
dirty = owned_changes(version)
|
|
16
|
+
source = [@project.version_file, @project.changelog].to_h do |path|
|
|
17
|
+
[path, @commands.call(["git", "show", "HEAD:#{path}"])]
|
|
18
|
+
end
|
|
19
|
+
if source.fetch(@project.changelog).include?("## [#{version}]")
|
|
20
|
+
raise Error, "Commit or stash unrelated work before continuing" unless dirty.empty?
|
|
21
|
+
|
|
22
|
+
@metadata.verify(tag: "v#{version}", version: @metadata.version_from(source.fetch(@project.version_file)),
|
|
23
|
+
changelog: source.fetch(@project.changelog))
|
|
24
|
+
@commands.call(@project.check_command, stream: true) unless dry_run
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
pending_changes(version, source)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(changes, version:)
|
|
32
|
+
targets = changes.to_h { |path, content| [@project.path(path), content] }
|
|
33
|
+
targets.each { |path, content| File.write(path, content) }
|
|
34
|
+
@commands.call(%w[bundle lock --local])
|
|
35
|
+
@commands.call(@project.generate_command, stream: true)
|
|
36
|
+
@commands.call(@project.check_command, stream: true)
|
|
37
|
+
owned_changes(version)
|
|
38
|
+
@commands.call(["git", "add", "--", *@project.release_files])
|
|
39
|
+
@commands.call(["git", "commit", "-S", "-m", "chore: release v#{version}"])
|
|
40
|
+
@commands.call(%w[git log -1 --format=full])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def pending_changes(version, source)
|
|
46
|
+
text = File.read(@project.path(@project.changelog))
|
|
47
|
+
dated = text[/^## \[#{Regexp.escape(version)}\] - (\d{4}-\d{2}-\d{2})$/, 1]
|
|
48
|
+
date = dated ? Date.iso8601(dated) : @clock.now.utc.to_date
|
|
49
|
+
changes = @metadata.changes(version, source: source, date: date)
|
|
50
|
+
changes.each do |path, content|
|
|
51
|
+
actual = File.read(@project.path(path))
|
|
52
|
+
next if [source.fetch(path), content].include?(actual)
|
|
53
|
+
|
|
54
|
+
raise Error, "#{path} has edits beyond release preparation; review them before retrying"
|
|
55
|
+
end
|
|
56
|
+
changes
|
|
57
|
+
rescue Date::Error
|
|
58
|
+
raise Error, "Invalid date in partially prepared release"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def owned_changes(version)
|
|
62
|
+
entries = @commands.call(%w[git status --porcelain -z]).split("\0")
|
|
63
|
+
paths = []
|
|
64
|
+
until entries.empty?
|
|
65
|
+
entry = entries.shift
|
|
66
|
+
paths << entry[3..]
|
|
67
|
+
paths << entries.shift if entry[0, 2].match?(/[RC]/)
|
|
68
|
+
end
|
|
69
|
+
unless paths.all? { |path| owned_path?(path) }
|
|
70
|
+
raise Error, "Unrelated changes on release/v#{version}; commit or stash them first"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
paths.each { |path| @project.path(path) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def owned_path?(path)
|
|
77
|
+
path && @project.release_files.any? { |owned| path == owned || path.start_with?("#{owned}/") }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRepoKit
|
|
4
|
+
module Release
|
|
5
|
+
# Remote tags are observation-only on retries; an accepted gem cannot be republished.
|
|
6
|
+
class Publication
|
|
7
|
+
def initialize(version, project:, commands:, clock: SystemClock, out: $stdout, branch: "main")
|
|
8
|
+
@version = version
|
|
9
|
+
@project = project
|
|
10
|
+
@commands = commands
|
|
11
|
+
@clock = clock
|
|
12
|
+
@out = out
|
|
13
|
+
@metadata = Metadata.new(project: project, commands: commands, clock: clock)
|
|
14
|
+
@base = @metadata.validate_branch(branch, version)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tag = "v#{@version}"
|
|
18
|
+
|
|
19
|
+
def remote_tag
|
|
20
|
+
@commands.call(["git", "ls-remote", "--tags", "origin", "refs/tags/#{tag}",
|
|
21
|
+
"refs/tags/#{tag}^{}"]).lines.to_h do |line|
|
|
22
|
+
sha, ref = line.split
|
|
23
|
+
[ref, sha]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(sha, push:, dry_run: false)
|
|
28
|
+
verify_commit(sha)
|
|
29
|
+
remote = remote_tag
|
|
30
|
+
if remote.any? && remote["refs/tags/#{tag}^{}"] != sha
|
|
31
|
+
raise Error, "Remote #{tag} targets another commit; it will never be moved"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
exists = !@commands.call(["git", "tag", "--list", tag]).strip.empty?
|
|
35
|
+
verify_tag(sha) if exists
|
|
36
|
+
if dry_run || (!push && remote.empty?)
|
|
37
|
+
next_step = exists ? "push the signed tag" : "sign and push its tag"
|
|
38
|
+
@out.puts "Release merge #{sha} is ready. Run #{resume_command} --push to #{next_step}."
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
if remote.empty?
|
|
42
|
+
@commands.call(["git", "tag", "-s", tag, "-m", "Release #{@version}", sha]) unless exists
|
|
43
|
+
verify_tag(sha)
|
|
44
|
+
@commands.call(["git", "push", "origin", tag], stream: true)
|
|
45
|
+
end
|
|
46
|
+
watch_release(sha)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def verify_commit(sha)
|
|
50
|
+
raise Error, "GitHub did not return a valid release merge commit" unless sha&.match?(/\A[0-9a-f]{40}\z/)
|
|
51
|
+
|
|
52
|
+
@commands.call(["git", "merge-base", "--is-ancestor", sha, "origin/#{@base}"])
|
|
53
|
+
verify_metadata(sha)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def verify_metadata(sha)
|
|
57
|
+
source = @commands.call(["git", "show", "#{sha}:#{@project.version_file}"])
|
|
58
|
+
version = @metadata.version_from(source)
|
|
59
|
+
changelog = @commands.call(["git", "show", "#{sha}:#{@project.changelog}"])
|
|
60
|
+
@metadata.verify(tag: tag, version: version, changelog: changelog)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resume_command
|
|
66
|
+
"bundle exec ruby-repo-kit release #{@version}#{" --branch #{@base}" unless @base == 'main'}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def verify_tag(sha)
|
|
70
|
+
unless @commands.call(["git", "cat-file", "-t", "refs/tags/#{tag}"]).strip == "tag" &&
|
|
71
|
+
@commands.call(["git", "rev-parse", "#{tag}^{commit}"]).strip == sha
|
|
72
|
+
raise Error, "Local #{tag} is not an annotated tag of the release merge; it will never be replaced"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@commands.call(["git", "verify-tag", tag])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def watch_release(sha)
|
|
79
|
+
run = find_run(sha)
|
|
80
|
+
unless run
|
|
81
|
+
raise Error, "#{tag} is already on GitHub. No Release run appeared; inspect Actions before dispatching it. " \
|
|
82
|
+
"The tag was not changed."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
id = run.fetch("databaseId").to_s
|
|
86
|
+
manual = run["event"] == "workflow_dispatch"
|
|
87
|
+
unless run["status"] == "completed"
|
|
88
|
+
@commands.call(["gh", "run", "watch", id], stream: true)
|
|
89
|
+
run = @commands.json(["gh", "run", "view", id, "--json", "status,conclusion"])
|
|
90
|
+
end
|
|
91
|
+
raise Error, failed_run_message(id) unless run["conclusion"] == "success"
|
|
92
|
+
|
|
93
|
+
if manual && !published_run?(id)
|
|
94
|
+
raise Error, "Manual run #{id} was successful, but publication was not confirmed. " \
|
|
95
|
+
"A dry run is not a release. Inspect gh run view #{id} --repo #{@project.repository}; " \
|
|
96
|
+
"no publication was retried."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@out.puts "Released #{tag}; the existing successful run is #{id}."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def find_run(sha)
|
|
103
|
+
60.times do
|
|
104
|
+
runs = @commands.json(["gh", "run", "list", "--workflow", @project.workflow,
|
|
105
|
+
"--branch", tag, "--limit", "20", "--json",
|
|
106
|
+
"databaseId,headSha,status,conclusion,event"])
|
|
107
|
+
matching = runs.select { |candidate| candidate["headSha"] == sha }
|
|
108
|
+
manual = matching.select { |candidate| candidate["event"] == "workflow_dispatch" }
|
|
109
|
+
recovered = manual.find do |candidate|
|
|
110
|
+
candidate["conclusion"] == "success" && published_run?(candidate.fetch("databaseId").to_s)
|
|
111
|
+
end
|
|
112
|
+
run = recovered || matching.find { |candidate| candidate["event"] == "push" } || manual.first
|
|
113
|
+
return run if run
|
|
114
|
+
|
|
115
|
+
@clock.sleep(5)
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def published_run?(id)
|
|
121
|
+
jobs = @commands.json(["gh", "run", "view", id, "--json", "jobs"]).fetch("jobs")
|
|
122
|
+
%w[publish github-release].all? do |name|
|
|
123
|
+
jobs.any? { |job| job["name"] == name && job["conclusion"] == "success" }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def failed_run_message(id)
|
|
128
|
+
jobs = @commands.json(["gh", "run", "view", id, "--json", "jobs"]).fetch("jobs")
|
|
129
|
+
publication = jobs.find { |job| job["name"] == "publish" }
|
|
130
|
+
github_release = jobs.find { |job| job["name"] == "github-release" }
|
|
131
|
+
prefix = "#{tag} is already on GitHub. Release run #{id} did not complete publication; " \
|
|
132
|
+
"no publication was retried."
|
|
133
|
+
if publication && publication["conclusion"] == "success" && github_release
|
|
134
|
+
"#{prefix}\nRubyGems publication succeeded. Retry only GitHub release creation:\n" \
|
|
135
|
+
"gh run rerun #{id} --job #{github_release.fetch('databaseId')} --repo #{@project.repository}"
|
|
136
|
+
elsif publication.nil? || publication["conclusion"] == "skipped"
|
|
137
|
+
"#{prefix}\nPublication did not run. Fix the failed checks, then: " \
|
|
138
|
+
"gh run rerun #{id} --failed --repo #{@project.repository}"
|
|
139
|
+
else
|
|
140
|
+
"#{prefix}\nInspect gh run view #{id} --log-failed --repo #{@project.repository}. " \
|
|
141
|
+
"Check whether RubyGems accepted #{@version} before retrying any publish job."
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRepoKit
|
|
4
|
+
module Release
|
|
5
|
+
class Publisher
|
|
6
|
+
def initialize(project:, commands: nil)
|
|
7
|
+
@project = project
|
|
8
|
+
@commands = commands || Commands.new(project: project)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def publish(expected: ENV.fetch("RELEASE_SHA256", nil), environment: ENV)
|
|
12
|
+
metadata = Metadata.new(project: @project)
|
|
13
|
+
version = metadata.version
|
|
14
|
+
unless environment["GITHUB_ACTIONS"] == "true" && environment["GITHUB_REPOSITORY"] == @project.repository &&
|
|
15
|
+
environment["GITHUB_REF"] == "refs/tags/v#{version}"
|
|
16
|
+
raise Error, "Publication runs only in this project's tagged GitHub Actions release workflow"
|
|
17
|
+
end
|
|
18
|
+
raise Error, "Publication requires the verified RELEASE_SHA256" unless expected&.match?(/\A[0-9a-f]{64}\z/)
|
|
19
|
+
|
|
20
|
+
metadata.verify(tag: "v#{version}")
|
|
21
|
+
artifact = Artifact.new(project: @project).verify(expected: expected)
|
|
22
|
+
@commands.call(["gem", "push", "--host", "https://rubygems.org", artifact], stream: true)
|
|
23
|
+
artifact
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module RubyRepoKit
|
|
6
|
+
module Release
|
|
7
|
+
class Workflow
|
|
8
|
+
def initialize(version, project:, commands: nil, configuration: nil, clock: SystemClock,
|
|
9
|
+
out: $stdout, push: false, dry_run: false, branch: "main")
|
|
10
|
+
raise Error, "--push and --dry-run cannot be combined" if push && dry_run
|
|
11
|
+
|
|
12
|
+
@version = version
|
|
13
|
+
@project = project
|
|
14
|
+
@push = push
|
|
15
|
+
@dry_run = dry_run
|
|
16
|
+
@out = out
|
|
17
|
+
@clock = clock
|
|
18
|
+
@commands = commands || Commands.new(project: project, out: out)
|
|
19
|
+
@metadata = Metadata.new(project: project, commands: @commands, clock: clock)
|
|
20
|
+
@base = @metadata.validate_branch(branch, version)
|
|
21
|
+
@preparation = Preparation.new(project: project, commands: @commands, metadata: @metadata, clock: clock)
|
|
22
|
+
@configuration = configuration
|
|
23
|
+
@publication = Publication.new(version, project: project, commands: @commands, out: out, clock: clock,
|
|
24
|
+
branch: @base)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
validate
|
|
29
|
+
request = find_pull_request
|
|
30
|
+
if request&.fetch("state") == "MERGED"
|
|
31
|
+
require_clean
|
|
32
|
+
@publication.run(request.dig("mergeCommit", "oid"), push: @push, dry_run: @dry_run)
|
|
33
|
+
elsif @publication.remote_tag.any?
|
|
34
|
+
raise Error, "#{tag} already exists without a matching merged release PR; inspect it before continuing"
|
|
35
|
+
elsif request
|
|
36
|
+
resume_pull_request(request)
|
|
37
|
+
else
|
|
38
|
+
prepare_pull_request
|
|
39
|
+
end
|
|
40
|
+
rescue Error => e
|
|
41
|
+
raise Error, "#{e.message}\nAfter resolving the problem, rerun #{resume_command}#{' --push' if @push}. " \
|
|
42
|
+
"Existing pull requests and tags are inspected before any new action."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def tag = "v#{@version}"
|
|
48
|
+
def branch = "release/#{tag}"
|
|
49
|
+
|
|
50
|
+
def resume_command
|
|
51
|
+
"bundle exec ruby-repo-kit release #{@version}#{" --branch #{@base}" unless @base == 'main'}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def command(...) = @commands.call(...)
|
|
55
|
+
|
|
56
|
+
def configuration
|
|
57
|
+
return @configuration if @configuration
|
|
58
|
+
|
|
59
|
+
require_relative "../github"
|
|
60
|
+
@configuration = GitHub::Configuration.new(project: @project)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate
|
|
64
|
+
origin = command(%w[git remote get-url origin]).strip
|
|
65
|
+
repository = Regexp.escape(@project.repository)
|
|
66
|
+
unless origin.match?(%r{\A(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)#{repository}(?:\.git)?\z})
|
|
67
|
+
raise Error, "The origin repository must be #{@project.repository}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
configuration.verify!
|
|
71
|
+
command(%w[git fetch origin --tags])
|
|
72
|
+
@current_branch = command(%w[git branch --show-current]).strip
|
|
73
|
+
raise Error, "Run from #{@base} or #{branch}" unless [@base, branch].include?(@current_branch)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def find_pull_request
|
|
77
|
+
requests = @commands.json(["gh", "pr", "list", "--state", "all", "--base", @base, "--head", branch,
|
|
78
|
+
"--json", "url,state,headRefOid,mergeCommit,isCrossRepository"])
|
|
79
|
+
if requests.any? { |request| request["isCrossRepository"] != false }
|
|
80
|
+
raise Error, "Release pull requests must originate in #{@project.repository}, not a fork"
|
|
81
|
+
end
|
|
82
|
+
raise Error, "Several pull requests use #{branch}; reconcile them before releasing" if requests.length > 1
|
|
83
|
+
|
|
84
|
+
requests.first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def require_clean
|
|
88
|
+
raise Error, "Commit or stash unrelated work before continuing" unless command(%w[git status --porcelain
|
|
89
|
+
-z]).empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resume_pull_request(request)
|
|
93
|
+
require_clean
|
|
94
|
+
unless request["state"] == "OPEN"
|
|
95
|
+
raise Error,
|
|
96
|
+
"The release PR was closed without merging; reopen it before retrying"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@commit = request.fetch("headRefOid")
|
|
100
|
+
raise Error, "GitHub did not return a valid release PR head" unless @commit&.match?(/\A[0-9a-f]{40}\z/)
|
|
101
|
+
if @current_branch == branch && command(%w[git rev-parse HEAD]).strip != @commit
|
|
102
|
+
raise Error, "Local #{branch} differs from the PR head. Push its reviewed changes before retrying"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if @dry_run
|
|
106
|
+
verify_pull_request
|
|
107
|
+
return @out.puts "Existing release PR: #{request.fetch('url')} (dry run)."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
finish_pull_request(request.fetch("url"))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def prepare_pull_request
|
|
114
|
+
if @current_branch == @base
|
|
115
|
+
require_clean
|
|
116
|
+
unless command(%w[git rev-parse HEAD]) == command(["git", "rev-parse", "origin/#{@base}"])
|
|
117
|
+
raise Error, "Local #{@base} must match origin/#{@base}; pull first"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
changes = @metadata.changes(@version)
|
|
121
|
+
if @dry_run
|
|
122
|
+
return @out.puts changes.fetch(@project.changelog),
|
|
123
|
+
"Dry run: no working files or GitHub state changed."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
command(["git", "switch", "-c", branch])
|
|
127
|
+
else
|
|
128
|
+
changes = @preparation.resume(@version, dry_run: @dry_run)
|
|
129
|
+
if @dry_run
|
|
130
|
+
return @out.puts "Would resume preparation on #{branch}; no working files or GitHub state changed."
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
@preparation.run(changes, version: @version) if changes
|
|
134
|
+
@commit = command(%w[git rev-parse HEAD]).strip
|
|
135
|
+
command(%w[git verify-commit HEAD])
|
|
136
|
+
command(["git", "push", "-u", "origin", branch], stream: true)
|
|
137
|
+
finish_pull_request(open_pull_request)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def open_pull_request
|
|
141
|
+
Tempfile.create(["#{@project.name}-release", ".md"]) do |body|
|
|
142
|
+
body.write("Release #{@project.name} #{@version}. The signed tag will target the merge commit.\n\n" \
|
|
143
|
+
"Validation: `#{@project.check_command.join(' ')}`. Release notes are in #{@project.changelog}.\n")
|
|
144
|
+
body.flush
|
|
145
|
+
command(["gh", "pr", "create", "--base", @base, "--head", branch, "--title", "chore: release #{tag}",
|
|
146
|
+
"--body-file", body.path]).strip
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def finish_pull_request(url)
|
|
151
|
+
verify_pull_request
|
|
152
|
+
return @out.puts "Release PR: #{url}. Run #{resume_command} --push to merge, sign and publish." unless @push
|
|
153
|
+
|
|
154
|
+
wait_for_checks(url)
|
|
155
|
+
command(["gh", "pr", "checks", url, "--watch", "--fail-fast", "--interval", "10"], stream: true)
|
|
156
|
+
command(["gh", "pr", "merge", url, "--merge", "--delete-branch", "--match-head-commit", @commit])
|
|
157
|
+
sha = command(["gh", "pr", "view", url, "--json", "mergeCommit", "--jq", ".mergeCommit.oid"]).strip
|
|
158
|
+
raise Error, "GitHub did not return a valid release merge commit" unless sha.match?(/\A[0-9a-f]{40}\z/)
|
|
159
|
+
|
|
160
|
+
command(%w[git fetch origin --tags])
|
|
161
|
+
command(["git", "merge-base", "--is-ancestor", @commit, sha])
|
|
162
|
+
@publication.run(sha, push: true)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def verify_pull_request
|
|
166
|
+
@publication.verify_metadata(@commit)
|
|
167
|
+
command(["git", "verify-commit", @commit])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def wait_for_checks(url)
|
|
171
|
+
60.times do
|
|
172
|
+
count = command(["gh", "pr", "view", url, "--json", "statusCheckRollup", "--jq",
|
|
173
|
+
".statusCheckRollup | length"])
|
|
174
|
+
return if count.to_i.positive?
|
|
175
|
+
|
|
176
|
+
@clock.sleep(5)
|
|
177
|
+
end
|
|
178
|
+
raise Error, "Timed out waiting for checks on #{url}; the existing PR will be reused on retry"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from Slipway and Rich-RI (MIT, Copyright 2026 Highlander Paiva).
|
|
4
|
+
require_relative "../ruby_repo_kit"
|
|
5
|
+
|
|
6
|
+
module RubyRepoKit
|
|
7
|
+
module Release
|
|
8
|
+
module SystemClock
|
|
9
|
+
def self.now = Time.now
|
|
10
|
+
def self.sleep(seconds) = Kernel.sleep(seconds)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "release/metadata"
|
|
16
|
+
require_relative "release/artifact"
|
|
17
|
+
require_relative "release/publication"
|
|
18
|
+
require_relative "release/preparation"
|
|
19
|
+
require_relative "release/workflow"
|
|
20
|
+
require_relative "release/publisher"
|