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
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).
|
data/config/rubocop.yml
ADDED
|
@@ -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,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
|