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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module RubyRepoKit
7
+ module GitHub
8
+ # HTTP status, including an allowed 404, is independent of gh's exit status.
9
+ # Pagination stays within this repository; credentials never enter argv or logs.
10
+ class Client
11
+ def initialize(project:, runner: nil)
12
+ @project = project
13
+ @runner = runner || method(:execute)
14
+ end
15
+
16
+ def request(path, method: "GET", body: nil, missing: false)
17
+ validate_request(path, method, missing)
18
+ result, following = fetch(path, method, body, missing)
19
+ seen = [path]
20
+ while following
21
+ raise Error, "GitHub returned pagination for a non-GET request" unless method == "GET"
22
+ raise Error, "GitHub pagination repeated a page" if seen.include?(following)
23
+
24
+ seen << following
25
+ page, following = fetch(following, "GET", nil, false)
26
+ result = Response.new(result.status, combine_pages(result.body, page.body))
27
+ end
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def validate_request(path, method, missing)
34
+ unless path.is_a?(String) && (path.empty? || path.start_with?("/")) &&
35
+ !path.match?(/[\x00-\x20\\#]/) && !path.split(%r{[/?]}).include?("..")
36
+ raise Error, "Expected a repository-relative GitHub API path"
37
+ end
38
+ raise Error, "Unsupported GitHub method" unless %w[GET POST PUT PATCH DELETE].include?(method)
39
+ raise Error, "Only GET requests may allow missing resources" if missing && method != "GET"
40
+ end
41
+
42
+ def fetch(path, method, body, missing)
43
+ argv = ["gh", "api", "--hostname", "github.com", "--include", "--method", method,
44
+ "-H", "Accept: application/vnd.github+json", "repos/#{@project.repository}#{path}"]
45
+ argv += ["--input", "-"] unless body.nil?
46
+ output, status = @runner.call(argv, stdin_data: body.nil? ? nil : JSON.generate(body))
47
+ header, content = output.to_s.split(/\r?\n\r?\n/, 2)
48
+ code = header[%r{\AHTTP/\S+ (\d{3})\b}, 1].to_i
49
+ raise Error, "GitHub #{method} #{path}: unreadable HTTP response" if code.zero?
50
+ unless (status.success? && (200..299).cover?(code)) || (missing && code == 404)
51
+ raise Error, "GitHub #{method} #{path}: HTTP #{code}"
52
+ end
53
+
54
+ parsed = content.to_s.strip.empty? ? {} : JSON.parse(content)
55
+ [Response.new(code, parsed), next_path(header)]
56
+ rescue JSON::ParserError
57
+ raise Error, "GitHub #{method} #{path}: invalid JSON response"
58
+ end
59
+
60
+ def next_path(header)
61
+ link = header.lines.find { |line| line.match?(/\Alink:/i) }
62
+ url = link&.match(/<([^>]+)>;\s*rel="next"/)&.[](1)
63
+ return unless url
64
+
65
+ prefix = "https://api.github.com/repos/#{@project.repository}"
66
+ raise Error, "GitHub pagination points outside the configured repository" unless url.start_with?("#{prefix}/")
67
+
68
+ path = url.delete_prefix(prefix)
69
+ validate_request(path, "GET", false)
70
+ path
71
+ end
72
+
73
+ def combine_pages(first, following)
74
+ return first + following if first.is_a?(Array) && following.is_a?(Array)
75
+ if first.is_a?(Hash) && following.is_a?(Hash) &&
76
+ first["branch_policies"].is_a?(Array) && following["branch_policies"].is_a?(Array)
77
+ return first.merge("branch_policies" => first["branch_policies"] + following["branch_policies"])
78
+ end
79
+
80
+ raise Error, "GitHub returned an unsupported paginated response"
81
+ end
82
+
83
+ def execute(argv, stdin_data: nil)
84
+ output, _error, status = Open3.capture3(*argv, stdin_data: stdin_data, chdir: @project.root)
85
+ [output, status]
86
+ rescue Errno::ENOENT
87
+ raise Error, "GitHub CLI (gh) is not installed or the project directory is unavailable"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRepoKit
4
+ module GitHub
5
+ class Configuration
6
+ def initialize(project:, client: nil, out: $stdout)
7
+ @project = project
8
+ @client = client || Client.new(project: project)
9
+ @policy = Policy.new(project: project)
10
+ @out = out
11
+ end
12
+
13
+ # A fresh GET-only plan on every invocation; no cached remote state is applied.
14
+ def plan
15
+ @changes = []
16
+ repository_settings
17
+ rulesets
18
+ environment
19
+ security_features
20
+ label
21
+ @changes.freeze
22
+ rescue KeyError, TypeError, NoMethodError => e
23
+ raise Error, "GitHub returned an incomplete configuration (#{e.class})"
24
+ end
25
+
26
+ def verify!
27
+ pending = plan
28
+ unless pending.empty?
29
+ descriptions = pending.map { |change| "- #{change.description}" }.join("\n")
30
+ raise Error, "Repository configuration needs attention:\n#{descriptions}\nRun github:plan, then github:apply."
31
+ end
32
+
33
+ @out.puts "GitHub repository policy verified for #{@project.repository}."
34
+ true
35
+ end
36
+
37
+ def apply!
38
+ pending = plan # Every read must succeed before the first write.
39
+ pending.each do |change|
40
+ @client.request(change.path, method: change.verb, body: change.body)
41
+ @out.puts "Configured #{change.description}."
42
+ end
43
+ verify!
44
+ end
45
+ alias setup apply!
46
+
47
+ private
48
+
49
+ def change(description, verb, path, body = nil)
50
+ @changes << Change.new(description, verb, path, body)
51
+ end
52
+
53
+ def get(path, **)
54
+ @client.request(path, **)
55
+ end
56
+
57
+ def object(value)
58
+ raise Error, "GitHub returned an object of an unexpected type" unless value.is_a?(Hash)
59
+
60
+ value
61
+ end
62
+
63
+ def list(value)
64
+ raise Error, "GitHub returned a list of an unexpected type" unless value.is_a?(Array) && value.all?(Hash)
65
+
66
+ value
67
+ end
68
+
69
+ # Order and extra server metadata are irrelevant; duplicates still count.
70
+ def matches?(actual, expected)
71
+ case expected
72
+ when Hash then actual.is_a?(Hash) && expected.all? { |key, value| matches?(actual[key], value) }
73
+ when Array
74
+ return false unless actual.is_a?(Array) && actual.length == expected.length
75
+
76
+ remaining = actual.dup
77
+ expected.all? do |value|
78
+ index = remaining.index { |item| matches?(item, value) }
79
+ index && remaining.delete_at(index)
80
+ end
81
+ else actual == expected
82
+ end
83
+ end
84
+
85
+ def repository_settings
86
+ current = object(get("").body)
87
+ raise Error, "Repository administrator access is required" unless current.dig("permissions", "admin") == true
88
+
89
+ change("merge settings", "PATCH", "", Policy::MERGE_SETTINGS) unless matches?(current, Policy::MERGE_SETTINGS)
90
+ scanning = %w[secret_scanning secret_scanning_push_protection].to_h { |key| [key, { "status" => "enabled" }] }
91
+ return if matches?(current["security_and_analysis"], scanning)
92
+
93
+ change("secret scanning and push protection", "PATCH", "", { "security_and_analysis" => scanning })
94
+ end
95
+
96
+ def rulesets
97
+ existing = list(get("/rulesets?includes_parents=false").body)
98
+ [@policy.main_ruleset, @policy.tags_ruleset].each do |desired|
99
+ found = existing.select { |ruleset| ruleset["name"] == desired["name"] }
100
+ raise Error, "Duplicate #{desired['name']} rulesets; reconcile them in GitHub first" if found.length > 1
101
+
102
+ if found.empty?
103
+ change("#{desired['name']} ruleset", "POST", "/rulesets", desired)
104
+ next
105
+ end
106
+ path = "/rulesets/#{identifier(found.first.fetch('id'))}"
107
+ current = object(get(path).body)
108
+ # Rules outside this policy remain project-owned when a managed rule changes.
109
+ managed = desired.fetch("rules").map { |rule| rule.fetch("type") }
110
+ extra = list(current.fetch("rules")).reject { |rule| managed.include?(rule.fetch("type")) }
111
+ replacement = desired.merge("rules" => desired.fetch("rules") + extra)
112
+ change("#{desired['name']} ruleset", "PUT", path, replacement) unless matches?(current, replacement)
113
+ end
114
+ end
115
+
116
+ def environment
117
+ path = "/environments/#{@project.environment}"
118
+ current = get(path, missing: true)
119
+ settings = object(current.body)
120
+ unless matches?(settings["deployment_branch_policy"], Policy::ENVIRONMENT)
121
+ body = preserved_protection(settings).merge("deployment_branch_policy" => Policy::ENVIRONMENT)
122
+ change("#{@project.environment} environment", "PUT", path, body)
123
+ end
124
+ policies = current.status == 404 ? [] : deployment_policies(path)
125
+ matching, extra = policies.partition { |policy| matches?(policy, Policy::TAG) }
126
+ # A duplicated matching policy is unnecessary and otherwise conceals drift.
127
+ (extra + matching.drop(1)).each do |policy|
128
+ change("remove extra #{@project.environment} deployment policy #{policy.fetch('name')}", "DELETE",
129
+ "#{path}/deployment-branch-policies/#{identifier(policy.fetch('id'))}")
130
+ end
131
+ return unless matching.empty?
132
+
133
+ change("#{@project.environment} deployments restricted to v* tags", "POST",
134
+ "#{path}/deployment-branch-policies", Policy::TAG)
135
+ end
136
+
137
+ def deployment_policies(path)
138
+ list(object(get("#{path}/deployment-branch-policies").body).fetch("branch_policies"))
139
+ end
140
+
141
+ def preserved_protection(settings)
142
+ list(settings.fetch("protection_rules", [])).each_with_object({}) do |rule, body|
143
+ case rule.fetch("type")
144
+ when "wait_timer" then body["wait_timer"] = rule.fetch("wait_timer")
145
+ when "required_reviewers"
146
+ body["prevent_self_review"] = rule.fetch("prevent_self_review")
147
+ body["reviewers"] = list(rule.fetch("reviewers")).map do |reviewer|
148
+ { "type" => reviewer.fetch("type"), "id" => object(reviewer.fetch("reviewer")).fetch("id") }
149
+ end
150
+ when "branch_policy" then next
151
+ else raise Error,
152
+ "Unknown environment protection #{rule['type'].inspect}; update its deployment policy manually"
153
+ end
154
+ end
155
+ end
156
+
157
+ def security_features
158
+ alerts = get("/vulnerability-alerts", missing: true)
159
+ change("vulnerability alerts", "PUT", "/vulnerability-alerts") if alerts.status == 404
160
+ %w[automated-security-fixes private-vulnerability-reporting immutable-releases].each do |feature|
161
+ enabled = object(get("/#{feature}").body).fetch("enabled")
162
+ raise Error, "GitHub returned an unknown #{feature} state" unless [true, false].include?(enabled)
163
+
164
+ change(feature.tr("-", " "), "PUT", "/#{feature}") unless enabled
165
+ end
166
+ end
167
+
168
+ def label
169
+ response = get("/labels/skip-changelog", missing: true)
170
+ change("skip-changelog label", "POST", "/labels", Policy::LABEL) if response.status == 404
171
+ end
172
+
173
+ def identifier(value)
174
+ raise Error, "GitHub returned an invalid resource identifier" unless value.is_a?(Integer) && value.positive?
175
+
176
+ value
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRepoKit
4
+ module GitHub
5
+ # The initial personal-project policy is explicit, separate from reconciliation.
6
+ class Policy
7
+ ACTIONS_APP_ID = 15_368
8
+ MERGE_SETTINGS = {
9
+ "allow_merge_commit" => true, "allow_squash_merge" => false, "allow_rebase_merge" => false,
10
+ "merge_commit_title" => "PR_TITLE", "merge_commit_message" => "BLANK", "delete_branch_on_merge" => true
11
+ }.freeze
12
+ ENVIRONMENT = { "protected_branches" => false, "custom_branch_policies" => true }.freeze
13
+ TAG = { "name" => "v*", "type" => "tag" }.freeze
14
+ LABEL = { "name" => "skip-changelog", "color" => "ededed",
15
+ "description" => "No CHANGELOG.md entry: no user-visible change" }.freeze
16
+
17
+ def initialize(project:)
18
+ @project = project
19
+ end
20
+
21
+ def main_ruleset
22
+ {
23
+ "name" => "main", "target" => "branch", "enforcement" => "active", "bypass_actors" => [],
24
+ "conditions" => { "ref_name" => { "include" => %w[refs/heads/main refs/heads/hotfix/*], "exclude" => [] } },
25
+ "rules" => [
26
+ { "type" => "pull_request", "parameters" => {
27
+ "required_approving_review_count" => 0, "dismiss_stale_reviews_on_push" => false,
28
+ "require_code_owner_review" => false, "require_last_push_approval" => false,
29
+ "required_review_thread_resolution" => true, "allowed_merge_methods" => ["merge"]
30
+ } },
31
+ { "type" => "required_status_checks", "parameters" => {
32
+ "strict_required_status_checks_policy" => true, "do_not_enforce_on_create" => false,
33
+ "required_status_checks" => @project.required_checks.map do |name|
34
+ { "context" => name, "integration_id" => ACTIONS_APP_ID }
35
+ end
36
+ } },
37
+ { "type" => "required_signatures" }, { "type" => "non_fast_forward" }, { "type" => "deletion" }
38
+ ]
39
+ }
40
+ end
41
+
42
+ def tags_ruleset
43
+ {
44
+ "name" => "tags", "target" => "tag", "enforcement" => "active",
45
+ "bypass_actors" => [{ "actor_id" => 5, "actor_type" => "RepositoryRole", "bypass_mode" => "always" }],
46
+ "conditions" => { "ref_name" => { "include" => ["refs/tags/v*"], "exclude" => [] } },
47
+ "rules" => %w[creation update deletion].map { |type| { "type" => type } }
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRepoKit
4
+ module GitHub
5
+ class Error < RubyRepoKit::Error; end
6
+
7
+ Response = Data.define(:status, :body)
8
+ Change = Data.define(:description, :verb, :path, :body)
9
+ end
10
+ end
11
+
12
+ require_relative "github/client"
13
+ require_relative "github/policy"
14
+ require_relative "github/configuration"
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "rubygems/package"
7
+ require "tmpdir"
8
+ require_relative "../ruby_repo_kit"
9
+ require_relative "specification"
10
+
11
+ module RubyRepoKit
12
+ # Builds the consumer and tests the installed artifact outside its bundle/checkout.
13
+ class Package
14
+ def initialize(project:, out: $stdout)
15
+ @project = project
16
+ @out = out
17
+ end
18
+
19
+ def build(output: nil)
20
+ spec = specification
21
+ target = output || @project.path("pkg/#{spec.name}-#{spec.version}.gem")
22
+ FileUtils.mkdir_p(File.dirname(target))
23
+ execute({}, [*gem_command, "build", @project.gemspec, "--output", target], chdir: @project.root)
24
+ verify(target)
25
+ target
26
+ end
27
+
28
+ def check(artifact: nil)
29
+ Dir.mktmpdir("ruby-repo-package-") do |dir|
30
+ package = artifact ? File.expand_path(artifact, @project.root) : build(output: File.join(dir, "package.gem"))
31
+ spec = verify(package)
32
+ home = File.join(dir, "gems")
33
+ cache_dependencies(dir)
34
+ Bundler.with_unbundled_env do
35
+ env = { "GEM_HOME" => home, "GEM_PATH" => home, "RUBYOPT" => nil, "RUBYLIB" => nil,
36
+ "XDG_CONFIG_HOME" => File.join(dir, "config"), "NO_COLOR" => "1" }
37
+ # GEM_HOME selects the destination. --install-dir would make RubyGems
38
+ # ignore installed specifications, including Ruby's uncached default gems.
39
+ execute(env, [*gem_command, "install", "--local", "--no-document", "--norc",
40
+ "--bindir", File.join(home, "bin"), package], chdir: dir)
41
+ spec.executables.each do |name|
42
+ executable = File.join(home, "bin", name)
43
+ version = execute(env, [executable, "--version"], chdir: dir)
44
+ unless version.match?(/(?<![\w.])#{Regexp.escape(spec.version.to_s)}(?![\w.])/)
45
+ raise Error, "Installed #{name} reports a different version"
46
+ end
47
+
48
+ help = execute(env, [executable, "--help"], chdir: dir)
49
+ raise Error, "Installed #{name} has empty help" if help.strip.empty?
50
+ end
51
+ yield env, home, dir if block_given?
52
+ end
53
+ @out.puts "Installed #{spec.name} #{spec.version} from the built gem in an isolated gem home."
54
+ spec
55
+ end
56
+ end
57
+
58
+ def verify(path)
59
+ package = Gem::Package.new(path)
60
+ package.verify
61
+ expected = specification
62
+ unless package.spec.name == expected.name && package.spec.version == expected.version
63
+ raise Error, "Package identity differs from #{@project.name} #{expected.version}"
64
+ end
65
+
66
+ package.spec
67
+ rescue Gem::Package::Error, Errno::ENOENT => e
68
+ raise Error, "Invalid gem artifact: #{e.message}"
69
+ end
70
+
71
+ private
72
+
73
+ def specification
74
+ spec = Specification.load(project: @project)
75
+ raise Error, "Gemspec name differs from project name" unless spec.name == @project.name
76
+
77
+ spec.files.each do |file|
78
+ raise Error, "Gemspec includes a missing file: #{file}" unless File.file?(@project.path(file))
79
+ end
80
+ spec
81
+ end
82
+
83
+ def gem_command
84
+ [RbConfig.ruby, "-rrubygems/gem_runner", "-e", "Gem::GemRunner.new.run(ARGV)", "--"]
85
+ end
86
+
87
+ def cache_dependencies(directory)
88
+ Bundler.load.specs.each do |spec|
89
+ FileUtils.cp(spec.cache_file, directory) if File.file?(spec.cache_file)
90
+ end
91
+ end
92
+
93
+ def execute(env, argv, chdir:)
94
+ output, status = Open3.capture2e(env, *argv, chdir: chdir)
95
+ raise Error, "#{argv.join(' ')} failed:\n#{output}" unless status.success?
96
+
97
+ output
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "psych"
5
+
6
+ module RubyRepoKit
7
+ # Explicit consumer configuration; no paths are resolved relative to the gem.
8
+ class Project
9
+ CONFIG_FILE = ".ruby-repo.yml"
10
+ DEFAULT_CHECKS = ["quality", "commits", "audit", "fresh-dependencies", "test (ubuntu-latest, 3.4)",
11
+ "test (ubuntu-latest, 4.0)", "test (macos-latest, 4.0)"].freeze
12
+ KEYS = %w[schema name repository version_file gemspec changelog generated_paths
13
+ generate_command check_command required_checks workflow environment].freeze
14
+ attr_reader :root, :name, :repository, :version_file, :gemspec, :changelog,
15
+ :generated_paths, :generate_command, :check_command, :required_checks,
16
+ :workflow, :environment
17
+
18
+ def self.load(root: Dir.pwd)
19
+ source = File.join(root, CONFIG_FILE)
20
+ new(root: root, settings: Psych.safe_load_file(source, permitted_classes: [], aliases: false))
21
+ rescue Errno::ENOENT => e
22
+ raise Error, "Cannot load project configuration: #{e.message}"
23
+ rescue Psych::Exception => e
24
+ raise Error, "Invalid #{CONFIG_FILE}: #{e.message}"
25
+ end
26
+
27
+ def initialize(root:, settings:)
28
+ raise Error, "Project configuration must be a mapping" unless settings.is_a?(Hash)
29
+ raise Error, "Unknown project settings: #{settings.keys - KEYS}" unless (settings.keys - KEYS).empty?
30
+ raise Error, "Unsupported configuration schema (expected 1)" unless settings.fetch("schema", nil) == 1
31
+
32
+ @root = File.realpath(root).freeze
33
+ @name = identifier(settings["name"], /\A[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*\z/, "gem name")
34
+ @repository = identifier(settings["repository"], %r{\A[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*\z},
35
+ "repository")
36
+ @version_file = relative(settings.fetch("version_file", "lib/#{name.tr('-', '_')}/version.rb"))
37
+ @gemspec = relative(settings.fetch("gemspec", "#{name}.gemspec"))
38
+ @changelog = relative(settings.fetch("changelog", "CHANGELOG.md"))
39
+ @generated_paths = string_array(settings.fetch("generated_paths", [])).map { |entry| relative(entry) }.freeze
40
+ @generate_command = argv(settings.fetch("generate_command", %w[bundle exec rake generate]))
41
+ @check_command = argv(settings.fetch("check_command", %w[bundle exec rake check]))
42
+ @required_checks = string_array(settings.fetch("required_checks", DEFAULT_CHECKS)).freeze
43
+ @workflow = identifier(settings.fetch("workflow", "release.yml"), /\A[a-zA-Z0-9_-]+\.ya?ml\z/,
44
+ "workflow filename")
45
+ @environment = identifier(settings.fetch("environment", "release"), /\A[a-zA-Z0-9_-]+\z/, "environment")
46
+ freeze
47
+ end
48
+
49
+ def url = "https://github.com/#{repository}"
50
+
51
+ def release_files = [version_file, changelog, "Gemfile.lock", *generated_paths].uniq
52
+
53
+ def path(relative_path)
54
+ value = relative(relative_path)
55
+ absolute = File.join(root, value)
56
+ ancestor = absolute
57
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
58
+ resolved = File.realpath(ancestor)
59
+ unless resolved == root || resolved.start_with?("#{root}/")
60
+ raise Error, "Project path escapes root through a symlink: #{value}"
61
+ end
62
+
63
+ absolute
64
+ rescue Errno::ENOENT
65
+ raise Error, "Project path contains a broken symlink: #{value}"
66
+ end
67
+
68
+ private
69
+
70
+ def identifier(value, pattern, description)
71
+ raise Error, "Invalid #{description}: #{value.inspect}" unless value.is_a?(String) && pattern.match?(value)
72
+
73
+ value.dup.freeze
74
+ end
75
+
76
+ def relative(value)
77
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0") &&
78
+ !Pathname.new(value).absolute? && !value.split("/").intersect?(["..", ".", ""])
79
+ raise Error, "Expected a relative project path: #{value.inspect}"
80
+ end
81
+
82
+ value.dup.freeze
83
+ end
84
+
85
+ def string_array(value)
86
+ unless value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) && !entry.empty? && !entry.include?("\0") }
87
+ raise Error, "Expected an array of nonempty strings"
88
+ end
89
+
90
+ value.map { |entry| entry.dup.freeze }
91
+ end
92
+
93
+ def argv(value)
94
+ result = string_array(value)
95
+ raise Error, "Commands require an executable and argv array" if result.empty?
96
+
97
+ result.freeze
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require_relative "../ruby_repo_kit"
5
+ require_relative "package"
6
+
7
+ module RubyRepoKit
8
+ # Explicit installation avoids task registration merely by requiring the gem.
9
+ class RakeTasks
10
+ extend Rake::DSL
11
+
12
+ def self.install(project:)
13
+ if Rake::Task.task_defined?("release")
14
+ raise Error,
15
+ "A release task already exists; remove competing release tooling before installing RubyRepoKit tasks"
16
+ end
17
+
18
+ desc "Validate local repository maintenance contracts"
19
+ task "repo:check" do
20
+ require_relative "checks"
21
+ Checks.new(project: project).run
22
+ end
23
+
24
+ desc "Build the distributable gem"
25
+ task :build do
26
+ puts Package.new(project: project).build
27
+ end
28
+
29
+ desc "Install and smoke-test the built gem outside the checkout and bundle"
30
+ task "package:check", [:artifact] do |_task, args|
31
+ Package.new(project: project).check(artifact: args[:artifact])
32
+ end
33
+
34
+ desc "Check commit and pull-request message conventions"
35
+ task "lint:commits", [:range] do |_task, args|
36
+ require_relative "checks/commits"
37
+ Commits.new(project: project).check(range: args[:range] || ENV.fetch("COMMIT_RANGE", nil),
38
+ title: ENV.fetch("PR_TITLE", nil), body: ENV.fetch("PR_BODY", nil))
39
+ end
40
+
41
+ desc "Audit locked dependencies against the latest advisory database"
42
+ task :audit do
43
+ sh "bundle", "exec", "bundler-audit", "check", "--update"
44
+ end
45
+
46
+ install_release(project)
47
+ install_github(project)
48
+ end
49
+
50
+ def self.install_release(project)
51
+ require_relative "release"
52
+
53
+ desc "Verify the release tag, version and changelog"
54
+ task "release:verify" do
55
+ metadata = Release::Metadata.new(project: project)
56
+ tag = ENV["GITHUB_REF_TYPE"] == "tag" ? ENV.fetch("GITHUB_REF_NAME", nil) : ENV.fetch("TAG", nil)
57
+ puts "Verified #{project.name} #{metadata.verify(tag: tag)}"
58
+ end
59
+
60
+ desc "Verify release commit ancestry"
61
+ task "release:verify_ref" do
62
+ Release::Metadata.new(project: project).verify_ref(sha: ENV.fetch("GITHUB_SHA"))
63
+ end
64
+
65
+ desc "Build and record the release artifact checksum and notes"
66
+ task "release:artifact" => :build do
67
+ puts Release::Artifact.new(project: project).record
68
+ end
69
+
70
+ desc "Verify existing release bytes without rebuilding"
71
+ task "release:verify_artifact" do
72
+ puts Release::Artifact.new(project: project).verify
73
+ end
74
+
75
+ desc "Publish verified artifact in the configured GitHub release workflow only"
76
+ task :release do
77
+ Release::Publisher.new(project: project).publish
78
+ end
79
+ end
80
+
81
+ def self.install_github(project)
82
+ %w[plan verify apply].each do |operation|
83
+ desc "#{operation.capitalize} GitHub repository policy"
84
+ task "github:#{operation}" do
85
+ require_relative "github"
86
+ configuration = GitHub::Configuration.new(project: project)
87
+ case operation
88
+ when "plan" then configuration.plan.each do |change|
89
+ puts "#{change.verb} #{change.path}: #{change.description}"
90
+ end
91
+ when "verify" then configuration.verify!
92
+ when "apply" then configuration.apply!
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end