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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +174 -0
  5. data/config/rubocop.yml +55 -0
  6. data/exe/ruby-repo-kit +6 -0
  7. data/lib/ruby_repo_kit/checks/commits.rb +84 -0
  8. data/lib/ruby_repo_kit/checks.rb +114 -0
  9. data/lib/ruby_repo_kit/cli.rb +128 -0
  10. data/lib/ruby_repo_kit/commands.rb +43 -0
  11. data/lib/ruby_repo_kit/github/client.rb +91 -0
  12. data/lib/ruby_repo_kit/github/configuration.rb +180 -0
  13. data/lib/ruby_repo_kit/github/policy.rb +52 -0
  14. data/lib/ruby_repo_kit/github.rb +14 -0
  15. data/lib/ruby_repo_kit/package.rb +100 -0
  16. data/lib/ruby_repo_kit/project.rb +100 -0
  17. data/lib/ruby_repo_kit/rake_tasks.rb +98 -0
  18. data/lib/ruby_repo_kit/release/artifact.rb +47 -0
  19. data/lib/ruby_repo_kit/release/metadata.rb +157 -0
  20. data/lib/ruby_repo_kit/release/preparation.rb +81 -0
  21. data/lib/ruby_repo_kit/release/publication.rb +146 -0
  22. data/lib/ruby_repo_kit/release/publisher.rb +27 -0
  23. data/lib/ruby_repo_kit/release/workflow.rb +182 -0
  24. data/lib/ruby_repo_kit/release.rb +20 -0
  25. data/lib/ruby_repo_kit/scaffold.rb +115 -0
  26. data/lib/ruby_repo_kit/specification.rb +44 -0
  27. data/lib/ruby_repo_kit/version.rb +5 -0
  28. data/lib/ruby_repo_kit.rb +10 -0
  29. data/templates/cli/.editorconfig.erb +12 -0
  30. data/templates/cli/.github/ISSUE_TEMPLATE/bug_report.yml.erb +18 -0
  31. data/templates/cli/.github/ISSUE_TEMPLATE/config.yml.erb +5 -0
  32. data/templates/cli/.github/ISSUE_TEMPLATE/feature_request.yml.erb +13 -0
  33. data/templates/cli/.github/PULL_REQUEST_TEMPLATE.md.erb +5 -0
  34. data/templates/cli/.github/dependabot.yml.erb +27 -0
  35. data/templates/cli/.github/workflows/ci.yml.erb +106 -0
  36. data/templates/cli/.github/workflows/release.yml.erb +139 -0
  37. data/templates/cli/.gitignore.erb +6 -0
  38. data/templates/cli/.rubocop.yml.erb +5 -0
  39. data/templates/cli/.ruby-repo.yml.erb +1 -0
  40. data/templates/cli/.ruby-version.erb +1 -0
  41. data/templates/cli/ARCHITECTURE.md.erb +25 -0
  42. data/templates/cli/CHANGELOG.md.erb +14 -0
  43. data/templates/cli/CODE_OF_CONDUCT.md.erb +14 -0
  44. data/templates/cli/CONTRIBUTING.md.erb +33 -0
  45. data/templates/cli/Gemfile.erb +17 -0
  46. data/templates/cli/LICENSE.txt.erb +21 -0
  47. data/templates/cli/README.md.erb +53 -0
  48. data/templates/cli/Rakefile.erb +38 -0
  49. data/templates/cli/SECURITY.md.erb +11 -0
  50. data/templates/cli/__name__.gemspec.erb +31 -0
  51. data/templates/cli/bin/release.erb +7 -0
  52. data/templates/cli/bin/setup.erb +8 -0
  53. data/templates/cli/docs/releasing.md.erb +42 -0
  54. data/templates/cli/exe/__name__.erb +6 -0
  55. data/templates/cli/lib/__require_path__/cli.rb.erb +50 -0
  56. data/templates/cli/lib/__require_path__/version.rb.erb +5 -0
  57. data/templates/cli/lib/__require_path__.rb.erb +4 -0
  58. data/templates/cli/test/cli_test.rb.erb +62 -0
  59. data/templates/cli/test/test_helper.rb.erb +15 -0
  60. 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"