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,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
|