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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2909f2324f7d85ad154aa579190c1a6163b2245b742e96075ba7f8ba45c0eb73
4
+ data.tar.gz: e67253ccb000a0c816a0fca22aaf9ae1e0984ca16344f4d1f814fe7df0f96ab0
5
+ SHA512:
6
+ metadata.gz: 2e008180735fbb712e1860ad300104fbecfa27c6d8e677e16487660bd976b3c6318a7778ac0fae1593a4f3b56fb783e622494aaf09ba9ea8310bc19c1905c24e
7
+ data.tar.gz: 5cb158be5ed9a2153ca460026b8184d88f7bd50b9fd6be22039badc17cd71f8d9d0a48485ad7d82b5b55a19c9565ef2d2b0ea81e1adcb34330b60ca62848c632
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - Generate focused Ruby CLI projects with tests, linting, documentation and release workflows.
10
+ - Share release preparation, recovery, artifact verification and isolated package checks through a development gem.
11
+ - Validate repository contracts and plan, verify or apply the initial GitHub maintenance policy.
12
+
13
+ [Unreleased]: https://github.com/hvpaiva/ruby-repo-kit/compare/v0.1.0...HEAD
14
+ [0.1.0]: https://github.com/hvpaiva/ruby-repo-kit/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Highlander Paiva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
20
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
21
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Ruby Repo Kit
2
+
3
+ Versioned maintenance tools and a focused generator for Ruby CLI repositories.
4
+ Create a project once; share release, packaging, repository checks and lint
5
+ configuration through a development dependency.
6
+
7
+ Ruby Repo Kit is in initial development. [Current status](docs/STATUS.md) records
8
+ the local, hosted CI and publication gates separately. A configured release
9
+ workflow or a passing local test does not prove that RubyGems OIDC publication
10
+ has succeeded. The dedicated ruby-repo-canary must pass its acceptance gates
11
+ before Slipway or Rich-RI adopt the toolkit.
12
+
13
+ Requires Ruby 3.4 or newer. GitHub operations additionally require Git, the
14
+ authenticated GitHub CLI, and the relevant repository permissions. Releases use
15
+ signed commits and tags and require working Git signing configuration.
16
+
17
+ ## Bootstrap from source
18
+
19
+ The toolkit develops and packages itself without depending on a published copy
20
+ of its own gem:
21
+
22
+ bundle install
23
+ bundle exec rake check
24
+ bundle exec ruby-repo-kit --help
25
+ bundle exec rake build
26
+
27
+ The build command prints the artifact path. Install that artifact with
28
+ gem install --local PATH_TO_ARTIFACT to exercise its installed executable and
29
+ templates. Development dependencies are installed by Bundler; installation of the
30
+ toolkit artifact is a separate validation step.
31
+
32
+ After a version has actually been published, it can also be installed from
33
+ RubyGems with gem install ruby-repo-kit.
34
+
35
+ ## Create a CLI
36
+
37
+ ruby-repo-kit new useful-cli --repository your-account/useful-cli --author "Your Name" --email you@example.com
38
+
39
+ Use --directory PATH to select another destination, including a path containing
40
+ spaces. Its parent directory must exist. Existing destinations, including
41
+ dangling symlinks, are rejected. Existing parent symlinks are resolved and the
42
+ command reports the canonical destination.
43
+
44
+ Generation creates files only: no Git repository, dependency installation,
45
+ GitHub resources or publication. The initial CLI prints text and provides help,
46
+ version, defined exit statuses and broken-pipe handling. The scaffold includes
47
+ tests, coverage, packaging, a shared lint preset, contribution documents,
48
+ dependency updates, CI and a release workflow.
49
+
50
+ Then enter the generated directory and run:
51
+
52
+ bin/setup
53
+ bundle exec rake check
54
+ bundle exec rake audit
55
+
56
+ Commit the resulting Gemfile.lock. The configured toolkit version must be
57
+ available to dependency resolution. Before its first publication, a locally
58
+ installed toolkit artifact can support a local canary; hosted CI needs an
59
+ accessible distribution of that version.
60
+
61
+ ## Adopt the shared tools
62
+
63
+ A consumer declares ruby-repo-kit in its Gemfile development/test group. Its
64
+ runtime gemspec must not depend on the toolkit. Existing projects need a reviewed
65
+ integration; the generator does not overwrite or migrate them.
66
+
67
+ The integration consists of:
68
+
69
+ - .ruby-repo.yml with project identity, release paths, commands and required checks.
70
+ - A Rakefile that installs RubyRepoKit::RakeTasks for that project.
71
+ - .rubocop.yml inheriting ruby-repo-kit's config/rubocop.yml.
72
+ - Optional thin executable wrappers such as bin/release.
73
+ - Project-owned CI and release workflow files.
74
+
75
+ Inspect the [architecture and configuration contract](docs/ARCHITECTURE.md) for
76
+ the Ruby API, ownership boundaries and configuration example.
77
+
78
+ ## Check a repository
79
+
80
+ bundle exec ruby-repo-kit doctor
81
+ bundle exec rake check
82
+ bundle exec rake audit
83
+
84
+ Doctor validates the local maintenance contract: required files, package
85
+ metadata, version consistency, package manifest, development dependency and
86
+ shared lint configuration. It does not certify code quality, run the full test
87
+ suite, or verify hosted settings.
88
+
89
+ The repository's check task combines lint, tests with coverage, structural checks
90
+ and installed-package smoke tests. The separate audit task updates the
91
+ vulnerability database and needs network access. Individual shared entry points
92
+ include repo:check, package:check, lint:commits and audit.
93
+
94
+ ## Prepare and publish a release
95
+
96
+ Start with the prospective version in a configured repository:
97
+
98
+ bundle exec ruby-repo-kit release 0.2.0 --dry-run
99
+
100
+ This inspects repository policy and release state and fetches Git refs. It does
101
+ not edit source files or change GitHub release state. Local main must match
102
+ origin/main, and the release needs a nonempty Unreleased changelog section.
103
+
104
+ To prepare the release:
105
+
106
+ bundle exec ruby-repo-kit release 0.2.0
107
+
108
+ This creates or resumes the release branch, updates version/changelog/lockfile,
109
+ runs generation and checks, makes a signed commit, pushes the branch and opens
110
+ or reuses a pull request. It is a write operation even without --push.
111
+
112
+ After reviewing the release:
113
+
114
+ bundle exec ruby-repo-kit release 0.2.0 --push
115
+
116
+ The additional --push option waits for checks, merges the exact release PR head,
117
+ signs and pushes a tag for the merge commit, and follows the hosted release run.
118
+ Use --branch hotfix/MAJOR.MINOR to target a maintenance branch for that version.
119
+ The --dry-run and --push options are mutually exclusive.
120
+
121
+ Rerunning the same version inspects existing PRs, tags and workflow runs before
122
+ continuing. Existing release tags are never moved. If RubyGems publication
123
+ succeeded but GitHub release creation failed, recovery reports the narrower
124
+ GitHub retry; it does not silently publish the gem again.
125
+
126
+ The hosted workflow builds and tests one gem artifact. Subsequent attestation,
127
+ RubyGems publishing and GitHub release creation use that artifact and verify its
128
+ SHA-256 digest. Publication uses the configured release environment and RubyGems
129
+ Trusted Publishing. The publisher must be configured separately on RubyGems;
130
+ GitHub policy setup does not register it.
131
+
132
+ The workflow's manual dry_run option rehearses build/verification without
133
+ publication. A matching version tag can publish once the external configuration
134
+ is in place. See [the acceptance plan](docs/PLAN.md) for the initial canary sequence.
135
+
136
+ ## Inspect and configure GitHub
137
+
138
+ bundle exec ruby-repo-kit github plan
139
+ bundle exec ruby-repo-kit github verify
140
+ bundle exec ruby-repo-kit github apply
141
+
142
+ Plan and verify read the configured repository. Apply reads its current state,
143
+ then applies the planned changes and verifies the result. It changes repository
144
+ settings; review a plan before choosing to apply it.
145
+
146
+ The initial policy is for personal repositories: signed commits, PR-based merge
147
+ commits, named required checks, protected release tags, a release environment
148
+ restricted to version tags, dependency/security settings, and the skip-changelog
149
+ label. It does not require another person's approval. Existing reviewer/timer
150
+ environment protections and unmanaged rules are preserved where supported;
151
+ unknown protections or unreadable settings stop reconciliation. Additional
152
+ deployment branch policies in the managed release environment are removed.
153
+ Administrator access and availability of these GitHub features are prerequisites.
154
+
155
+ ## Update without copying scripts
156
+
157
+ bundle update ruby-repo-kit
158
+ bundle exec rake check
159
+
160
+ Review and commit the dependency/lockfile change. Shared operations and the
161
+ inherited lint preset then use the new version. Templates affect newly generated
162
+ projects; updating the dependency does not rewrite existing application code,
163
+ tests, documentation or local workflow YAML. Integration changes are explicit
164
+ reviewed edits. During the initial 0.x series, review release notes for each update.
165
+
166
+ ## Contributing and license
167
+
168
+ See [contributing](CONTRIBUTING.md), [security](SECURITY.md),
169
+ [conduct](CODE_OF_CONDUCT.md), [decisions](docs/DECISIONS.md) and the
170
+ [implementation plan](docs/PLAN.md).
171
+
172
+ MIT, Copyright (c) 2026 Highlander Paiva. Release tooling incorporates code adapted
173
+ from Highlander Paiva's Slipway and Rich-RI projects under the same MIT license.
174
+ See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,55 @@
1
+ plugins:
2
+ - rubocop-minitest
3
+ - rubocop-performance
4
+ - rubocop-rake
5
+
6
+ inherit_mode:
7
+ merge:
8
+ - Exclude
9
+
10
+ AllCops:
11
+ NewCops: enable
12
+ TargetRubyVersion: 3.4
13
+ Exclude:
14
+ - vendor/**/*
15
+ - tmp/**/*
16
+ - pkg/**/*
17
+
18
+ Style/StringLiterals:
19
+ EnforcedStyle: double_quotes
20
+
21
+ Style/Documentation:
22
+ Enabled: false
23
+
24
+ Metrics/BlockLength:
25
+ Exclude:
26
+ - test/**/*
27
+ - Rakefile
28
+ - '*.gemspec'
29
+
30
+ Metrics/MethodLength:
31
+ Max: 35
32
+
33
+ Metrics/ParameterLists:
34
+ CountKeywordArgs: false
35
+
36
+ Metrics/AbcSize:
37
+ Max: 46
38
+
39
+ Metrics/CyclomaticComplexity:
40
+ Max: 18
41
+
42
+ Metrics/PerceivedComplexity:
43
+ Max: 18
44
+
45
+ Metrics/ClassLength:
46
+ Max: 180
47
+
48
+ Minitest/MultipleAssertions:
49
+ Max: 12
50
+
51
+ # Validation methods raise detailed errors on failure and return true on success.
52
+ Naming/PredicateMethod:
53
+ AllowedMethods:
54
+ - verify!
55
+ - check
data/exe/ruby-repo-kit ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ruby_repo_kit/cli"
5
+
6
+ exit RubyRepoKit::CLI.run(ARGV)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRepoKit
4
+ # A read-only Conventional Commits policy for commits and pull request metadata.
5
+ class Commits
6
+ TYPES = %w[feat fix docs test refactor chore ci].freeze
7
+ SUBJECT = /\A(?:#{TYPES.join('|')})(?:\([a-z0-9]+(?:-[a-z0-9]+)*\))?!?: (?:\[security\] )?[a-z][^\r\n]*\z/
8
+ UNFINISHED = /\A(?:fixup!|squash!|amend!|wip\b)|\A\w+(?:\([^)]*\))?!?:\s*wip\b/i
9
+ REVERT = /\A(?:Revert|Reapply) "(?<subject>.+)"\z/
10
+ BOT_ADDRESS = /\A(?:noreply@anthropic\.com|cursoragent@cursor\.com|(?:aider|noreply)@aider\.chat|
11
+ \d+\+(?:Copilot|[\w-]+\[bot\])@users\.noreply\.github\.com)\z/ix
12
+ TOOL_TRAILER = /^(?:Generated-(?:by|with)|Assisted-by|Claude-Session):.*\b
13
+ (?:claude|anthropic|copilot|cursor|codex|chatgpt|gpt-|gemini|assistant)\b/ix
14
+
15
+ def initialize(project:, commands: nil)
16
+ @commands = commands || Commands.new(project: project)
17
+ end
18
+
19
+ def check(range: nil, title: ENV.fetch("PR_TITLE", nil), body: ENV.fetch("PR_BODY", nil))
20
+ range ||= default_range
21
+ validate_range(range)
22
+ output = @commands.call(["git", "log", "--no-show-signature", "--reverse", "-z",
23
+ "--format=%h%x1f%p%x1f%B", range, "--"])
24
+ problems = commit_problems(output)
25
+ problems << "Pull request title is not a Conventional Commit" if title && !valid_subject?(title.strip)
26
+ problems << "Pull request body contains a tool attribution trailer" if body && attribution?(body)
27
+ raise Error, "Commit policy failed:\n#{problems.join("\n")}" unless problems.empty?
28
+
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ def default_range
35
+ refs = @commands.call(["git", "for-each-ref", "--format=%(refname)", "refs/remotes/origin/main"])
36
+ refs.lines.map(&:strip).include?("refs/remotes/origin/main") ? "origin/main..HEAD" : "HEAD"
37
+ end
38
+
39
+ def validate_range(range)
40
+ references = range.is_a?(String) ? range.split(/\.\.\.?/, -1) : []
41
+ valid = references.length.between?(1, 2) && references.all? do |reference|
42
+ reference.match?(%r{\A[A-Za-z0-9][A-Za-z0-9._/-]*\z}) &&
43
+ !reference.include?("..") && !reference.include?("//") && !reference.end_with?("/") &&
44
+ reference.split("/").none? { |part| part.start_with?(".") || part.end_with?(".", ".lock") }
45
+ end
46
+ raise Error, "Expected a commit reference or REF..REF range" unless valid
47
+ end
48
+
49
+ def commit_problems(output)
50
+ output.dup.force_encoding(Encoding::UTF_8).scrub.split("\0").flat_map do |record|
51
+ sha, parents, message = record.split("\x1f", 3)
52
+ raise Error, "Git returned an unreadable commit record" unless sha && parents && message
53
+
54
+ problems = []
55
+ if parents.split.length < 2 && !valid_subject?(message.lines.first.to_s.chomp)
56
+ problems << "#{sha}: subject is not a Conventional Commit"
57
+ end
58
+ problems << "#{sha}: message contains a tool attribution trailer" if attribution?(message)
59
+ problems
60
+ end
61
+ end
62
+
63
+ def valid_subject?(subject)
64
+ return false if UNFINISHED.match?(subject)
65
+
66
+ # git can nest Revert/Reapply subjects; iteration avoids recursive stack growth.
67
+ while (reverted = REVERT.match(subject))
68
+ subject = reverted[:subject]
69
+ end
70
+ !UNFINISHED.match?(subject) && SUBJECT.match?(subject)
71
+ end
72
+
73
+ def attribution?(text)
74
+ text.each_line.any? do |line|
75
+ address = line.match(/^Co-Authored-By:[^<\n]*<(?<address>[^>\n]+)>/i)
76
+ if address
77
+ BOT_ADDRESS.match?(address[:address].strip)
78
+ else
79
+ TOOL_TRAILER.match?(line) || line.match?(/^\W*Generated with \[?Claude/i)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "psych"
6
+ require "rbconfig"
7
+ require_relative "specification"
8
+
9
+ module RubyRepoKit
10
+ # Local structural checks. Evaluating a gemspec/Gemfile is trusted project code;
11
+ # a child interpreter avoids loading consumer constants into the toolkit process.
12
+ class Checks
13
+ TOOLKIT_NAME = "ruby-repo-kit"
14
+ PRESET = "config/rubocop.yml"
15
+ GEMFILE_SCRIPT = <<~RUBY
16
+ require "json"
17
+ require "bundler"
18
+ definition = Bundler::Dsl.evaluate("Gemfile", nil, {})
19
+ puts JSON.generate(definition.dependencies.map(&:name))
20
+ RUBY
21
+
22
+ def initialize(project:)
23
+ @project = project
24
+ end
25
+
26
+ def run
27
+ [@project.gemspec, @project.version_file, @project.changelog, "Gemfile", "Rakefile"].each do |file|
28
+ require_file(file)
29
+ end
30
+ specification = Specification.load(project: @project)
31
+ metadata(specification)
32
+ manifest(specification)
33
+ development_dependency(specification)
34
+ rubocop_preset unless @project.name == TOOLKIT_NAME
35
+ true
36
+ rescue Errno::ENOENT, Errno::EACCES, Psych::Exception => e
37
+ raise Error, "Project checks failed: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def require_file(file)
43
+ path = @project.path(file)
44
+ raise Error, "Required project file is missing: #{file}" unless File.file?(path)
45
+
46
+ path
47
+ end
48
+
49
+ def metadata(specification)
50
+ unless specification.name == @project.name
51
+ raise Error, "Gemspec name differs from project configuration (expected #{@project.name})"
52
+ end
53
+
54
+ text = File.read(@project.path(@project.version_file))
55
+ versions = text.scan(/^\s*VERSION\s*=\s*(["'])([^"'\n]+)\1(?:\.freeze)?\s*(?:#.*)?$/).map(&:last)
56
+ unless versions.length == 1 && Gem::Version.correct?(versions.first)
57
+ raise Error, "#{@project.version_file} must contain one literal VERSION assignment"
58
+ end
59
+ return if Gem::Version.new(versions.first) == specification.version
60
+
61
+ raise Error, "Gemspec version differs from #{@project.version_file}"
62
+ end
63
+
64
+ def manifest(specification)
65
+ files = specification.files
66
+ raise Error, "Gemspec files must be a list of relative paths" unless files.is_a?(Array) && files.all?(String)
67
+
68
+ files.each { |file| require_file(file) }
69
+ unless files.include?(@project.version_file) && files.any? { |file| file.start_with?("lib/") }
70
+ raise Error, "Gem manifest must include its lib files and #{@project.version_file}"
71
+ end
72
+
73
+ specification.executables.each do |executable|
74
+ relative = File.join(specification.bindir, executable)
75
+ require_file(relative)
76
+ raise Error, "Gem manifest does not include executable #{relative}" unless files.include?(relative)
77
+ end
78
+ end
79
+
80
+ def development_dependency(specification)
81
+ return if @project.name == TOOLKIT_NAME
82
+ if specification.runtime_dependencies.any? { |dependency| dependency.name == TOOLKIT_NAME }
83
+ raise Error, "#{TOOLKIT_NAME} belongs in the consumer Gemfile, never its runtime dependencies"
84
+ end
85
+ return if gemfile_dependencies.include?(TOOLKIT_NAME)
86
+
87
+ raise Error, "Declare #{TOOLKIT_NAME} in the consumer Gemfile"
88
+ end
89
+
90
+ def gemfile_dependencies
91
+ environment = ENV.keys.grep(/\ABUNDL/).to_h { |key| [key, nil] }
92
+ environment["RUBYOPT"] = nil
93
+ environment["RUBYLIB"] = nil
94
+ output, error, status = Open3.capture3(environment, RbConfig.ruby, "-e", GEMFILE_SCRIPT, chdir: @project.root)
95
+ raise Error, "Cannot inspect project Gemfile: #{error.strip}" unless status.success?
96
+
97
+ JSON.parse(output)
98
+ rescue JSON::ParserError
99
+ raise Error, "The Gemfile inspector returned invalid JSON (check output from the Gemfile)"
100
+ end
101
+
102
+ def rubocop_preset
103
+ path = @project.path(".rubocop.yml")
104
+ return unless File.exist?(path)
105
+
106
+ configuration = Psych.safe_load_file(path, permitted_classes: [], aliases: true)
107
+ inherited = configuration.is_a?(Hash) && configuration["inherit_gem"]
108
+ presets = inherited.is_a?(Hash) && inherited[TOOLKIT_NAME]
109
+ return if Array(presets).include?(PRESET)
110
+
111
+ raise Error, ".rubocop.yml must inherit #{TOOLKIT_NAME}/#{PRESET}; local overrides remain allowed"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../ruby_repo_kit"
5
+
6
+ module RubyRepoKit
7
+ class CLI
8
+ HELP = <<~TEXT
9
+ Usage: ruby-repo-kit COMMAND [OPTIONS]
10
+
11
+ new NAME Generate a Ruby CLI in a new directory
12
+ doctor Check this project's local maintenance contract
13
+ release X.Y.Z Prepare or resume a release (--dry-run, --push, --branch)
14
+ github plan Show repository-policy changes without applying them
15
+ github verify Verify repository policy without writes
16
+ github apply Explicitly apply the repository's policy
17
+
18
+ --help Show this help
19
+ --version Show the toolkit version
20
+
21
+ Project commands read .ruby-repo.yml in the current directory.
22
+ TEXT
23
+
24
+ def self.run(argv, root: Dir.pwd, out: $stdout, err: $stderr)
25
+ new(root: root, out: out, err: err).run(argv.dup)
26
+ end
27
+
28
+ def initialize(root:, out:, err:)
29
+ @root = root
30
+ @out = out
31
+ @err = err
32
+ end
33
+
34
+ def run(argv)
35
+ command = argv.shift
36
+ case command
37
+ when nil, "--help", "-h" then @out.puts HELP
38
+ when "--version" then @out.puts "ruby-repo-kit #{VERSION}"
39
+ when "new" then generate(argv)
40
+ when "doctor" then doctor(argv)
41
+ when "release" then release(argv)
42
+ when "github" then github(argv)
43
+ else raise OptionParser::InvalidArgument, "unknown command #{command.inspect}"
44
+ end
45
+ 0
46
+ rescue OptionParser::ParseError => e
47
+ @err.puts "ruby-repo-kit: #{e.message}"
48
+ 2
49
+ rescue Error => e
50
+ @err.puts "ruby-repo-kit: #{e.message}"
51
+ 1
52
+ rescue Errno::EPIPE
53
+ 0
54
+ end
55
+
56
+ private
57
+
58
+ def project
59
+ @project ||= Project.load(root: @root)
60
+ end
61
+
62
+ def generate(argv)
63
+ require_relative "scaffold"
64
+ settings = {}
65
+ parser = OptionParser.new do |options|
66
+ options.on("--repository OWNER/NAME") { |value| settings[:repository] = value }
67
+ options.on("--directory PATH") { |value| settings[:destination] = File.expand_path(value, @root) }
68
+ options.on("--author NAME") { |value| settings[:author] = value }
69
+ options.on("--email ADDRESS") { |value| settings[:email] = value }
70
+ end
71
+ name = one_argument(parser.parse(argv), "new requires one gem name")
72
+ %i[repository author email].each do |key|
73
+ raise OptionParser::MissingArgument, "--#{key}" unless settings.key?(key)
74
+ end
75
+ settings[:destination] ||= File.join(@root, name)
76
+ destination = Scaffold.new(name: name, **settings).generate
77
+ @out.puts "Created #{destination}. Run bin/setup there to install development dependencies."
78
+ end
79
+
80
+ def doctor(argv)
81
+ require_relative "checks"
82
+ raise OptionParser::InvalidArgument, "doctor takes no arguments" unless argv.empty?
83
+
84
+ Checks.new(project: project).run
85
+ @out.puts "Local maintenance contract verified for #{project.name}."
86
+ end
87
+
88
+ def release(argv)
89
+ require_relative "release"
90
+ settings = { dry_run: false, push: false, branch: "main" }
91
+ parser = OptionParser.new do |options|
92
+ options.on("--dry-run") { settings[:dry_run] = true }
93
+ options.on("--push") { settings[:push] = true }
94
+ options.on("--branch NAME") { |value| settings[:branch] = value }
95
+ end
96
+ version = one_argument(parser.parse(argv), "release requires one X.Y.Z version")
97
+ if settings[:dry_run] && settings[:push]
98
+ raise OptionParser::InvalidArgument, "--dry-run and --push are mutually exclusive"
99
+ end
100
+
101
+ Release::Workflow.new(version, project: project, out: @out, **settings).run
102
+ end
103
+
104
+ def github(argv)
105
+ require_relative "github"
106
+ action = one_argument(argv, "github requires plan, verify or apply")
107
+ unless %w[plan verify apply].include?(action)
108
+ raise OptionParser::InvalidArgument, "unknown github action #{action.inspect}"
109
+ end
110
+
111
+ configuration = GitHub::Configuration.new(project: project, out: @out)
112
+ case action
113
+ when "plan"
114
+ pending = configuration.plan
115
+ pending.each { |change| @out.puts "#{change.verb} #{change.path}: #{change.description}" }
116
+ @out.puts "No repository-policy changes required." if pending.empty?
117
+ when "verify" then configuration.verify!
118
+ when "apply" then configuration.apply!
119
+ end
120
+ end
121
+
122
+ def one_argument(argv, message)
123
+ raise OptionParser::InvalidArgument, message unless argv.length == 1
124
+
125
+ argv.first
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module RubyRepoKit
8
+ # All commands run in the consumer checkout and are passed as argv, never shell text.
9
+ class Commands
10
+ def initialize(project:, runner: nil, out: $stdout)
11
+ @project = project
12
+ @runner = runner || method(:execute)
13
+ @out = out
14
+ end
15
+
16
+ def call(argv, stream: false)
17
+ command = argv.dup
18
+ command += ["--repo", @project.repository] if command.first == "gh" && %w[pr run release].include?(command[1])
19
+ @out.puts "==> #{command.join(' ')}"
20
+ output, status = @runner.call(command, stream: stream)
21
+ raise Error, "#{command.join(' ')} failed.\n#{output}" unless status.success?
22
+
23
+ output
24
+ end
25
+
26
+ def json(argv)
27
+ JSON.parse(call(argv))
28
+ rescue JSON::ParserError => e
29
+ raise Error, "Command returned invalid JSON: #{e.message}"
30
+ end
31
+
32
+ private
33
+
34
+ def execute(argv, stream: false)
35
+ return Open3.capture2e(*argv, chdir: @project.root) unless stream
36
+
37
+ system(*argv, chdir: @project.root)
38
+ ["", $CHILD_STATUS]
39
+ rescue Errno::ENOENT => e
40
+ raise Error, "Required command is unavailable: #{e.message}"
41
+ end
42
+ end
43
+ end