gempilot 0.3.1 → 0.3.2
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 +4 -4
- data/.ruby-version +1 -1
- data/data/templates/gem/Gemfile.erb +1 -0
- data/docs/superpowers/plans/2026-09-23-command-generation-bootstrap.md +810 -0
- data/docs/superpowers/plans/2026-09-23-land-betterleaks-jruby.md +262 -0
- data/docs/superpowers/plans/2026-09-23-multi-gem-rubygems-push.md +318 -0
- data/docs/superpowers/plans/2026-09-23-zeitwerk-task-inflector.md +911 -0
- data/issues.rec +18 -1
- data/lib/gempilot/cli/command.rb +1 -1
- data/lib/gempilot/version.rb +1 -1
- data/vendor/vendored.gemv +0 -0
- metadata +6 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Land the betterleaks-jruby Branch Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Bring `origin/betterleaks-jruby` — which already implements issues `986E0100` (MRI-only dev gems) and `3FFE7616` (betterleaks integration), with its own approved design spec and plan committed on the branch — onto today's master, and get it merged. Nothing is re-planned here; the feature work is done.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The branch is 10 commits from 2026-07-26 (design spec, plan, `platforms :mri` block in gempilot's Gemfile, `Gempilot::BetterleaksTask`, hook and `secrets.yml` templates, `--[no-]betterleaks` on `create`, a `gempilot setup betterleaks` retrofit command, dogfooding, issue closures) plus one Aug 7 commit filing issue `F7C6A9AA`. Master moved since (dev-version-bump merge, the `json` pin), so merging master into the branch produces exactly one conflict (`CLAUDE.md`; `issues.rec` auto-merges), and master's own `json`-ordering regression must be fixed for the gate to go green. The branch's published history is never rewritten and nothing in this plan pushes. Verified 2026-09-23 in a scratch copy: after the merge, that one-line fix, and Task 3, the full gate is `122 runs, 0 failures`, `205 examples, 0 failures`, `69 files inspected, no offenses`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** git rebase, GNU recutils (`recset`, `recsel`, `recfix`) for `issues.rec`, `bundle exec rake default`.
|
|
10
|
+
|
|
11
|
+
**Issues:** `986E0100-88F4-11F1-B718-FE6CB9572C2D`, `3FFE7616-88F5-11F1-8D3B-FE6CB9572C2D` (both closed by the branch's own `issues.rec` commit).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Do not redesign anything on the branch; its spec (`docs/superpowers/specs/2026-07-26-betterleaks-and-jruby-design.md` on the branch) is marked approved. Notable decisions it made: the hook and `rake betterleaks` skip with an install hint when the binary is missing (fail open; CI enforces), CI is a separate `secrets.yml` using `go install github.com/betterleaks/betterleaks@latest`, and Issue A scopes `platforms :mri` to gempilot's own Gemfile only.
|
|
16
|
+
- Master's uncommitted working-tree change to `issues.rec` (closing `2360FFA4`) is separate from this branch; commit it on master before or after, never inside the rebase.
|
|
17
|
+
- Commit messages: plain imperative sentences, no conventional-commit prefixes.
|
|
18
|
+
- `bundle install` cannot materialize master's lockfile on machines without the author's vault (rake 13.4.3); run the gate where the bundle already works.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### Task 1: Restore alphabetical order in the Gemfile template
|
|
23
|
+
|
|
24
|
+
**Files:**
|
|
25
|
+
- Modify: `data/templates/gem/Gemfile.erb:5-7`
|
|
26
|
+
|
|
27
|
+
**Interfaces:**
|
|
28
|
+
- Produces: a template whose `gem` lines are sorted; four master tests go green (`test_gemfile_gems_are_alphabetically_ordered`, its rspec twin, and both `*_default_rake_task_passes` integration tests, which fail today because the generated gem's RuboCop reports `Gemfile:6 Bundler/OrderedGems`).
|
|
29
|
+
|
|
30
|
+
- [ ] **Step 1: See the failures on master**
|
|
31
|
+
|
|
32
|
+
Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/create_command_test.rb -n "/gemfile/i"`
|
|
33
|
+
Expected: `4 runs, ... 2 failures`, actual list starting `["json", "gempilot", "irb", ...]`.
|
|
34
|
+
|
|
35
|
+
- [ ] **Step 2: Move the json line**
|
|
36
|
+
|
|
37
|
+
Replace the top of `data/templates/gem/Gemfile.erb` (everything above the first `<% if` line) with:
|
|
38
|
+
|
|
39
|
+
```erb
|
|
40
|
+
source "https://rubygems.org"
|
|
41
|
+
|
|
42
|
+
gemspec
|
|
43
|
+
|
|
44
|
+
gem "gempilot", require: false
|
|
45
|
+
gem "irb"
|
|
46
|
+
gem "json", "~> 2.21" # temporary json 3.0.0 is currently breaking everything
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 3: Verify and commit on master**
|
|
50
|
+
|
|
51
|
+
Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/create_command_test.rb`
|
|
52
|
+
Expected: `53 runs, 0 failures`.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git add data/templates/gem/Gemfile.erb
|
|
56
|
+
git commit -m "Restore alphabetical order in the Gemfile template"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
(Open this as its own one-commit PR, or fold it in as the first commit of the rebased branch; either way it must precede Task 3's gate.)
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### Task 2: Merge master into the branch
|
|
64
|
+
|
|
65
|
+
No rebase and no force-push: the branch's published history stays as it is, master is merged in, and the merge commit is what gets pushed later, normally.
|
|
66
|
+
|
|
67
|
+
**Files:**
|
|
68
|
+
- Conflict to resolve: `CLAUDE.md` (once). `issues.rec` auto-merges.
|
|
69
|
+
|
|
70
|
+
**Interfaces:**
|
|
71
|
+
- Consumes: `origin/betterleaks-jruby`, master with Task 1.
|
|
72
|
+
- Produces: local branch `betterleaks-jruby` = its 11 commits + one merge commit, no conflict markers, `recfix` clean.
|
|
73
|
+
|
|
74
|
+
- [ ] **Step 1: Start the merge**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git fetch origin
|
|
78
|
+
git checkout betterleaks-jruby
|
|
79
|
+
git merge master
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Expected: `Auto-merging issues.rec` succeeds; `Automatic merge failed` only because of `CLAUDE.md` (`git diff --name-only --diff-filter=U` prints exactly `CLAUDE.md`).
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 2: Check the auto-merged issues.rec**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
recfix issues.rec
|
|
88
|
+
recsel -e "Status = 'open'" -P Id issues.rec
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Expected: `recfix` is silent; the open list no longer contains `986E0100` or `3FFE7616` and does contain `F7C6A9AA` (the branch's new issue, "gempilot new class WebSocket::Server should respect zeitwerk inflectors if present") alongside master's issues.
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 3: Resolve CLAUDE.md**
|
|
94
|
+
|
|
95
|
+
The conflict is the Commands list. Keep master's `gempilot bump` line (it mentions `tiny/dev`; the branch's copy is stale) and the branch's new `gempilot setup` line, so the resolved lines read:
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+
- `gempilot setup` — Retrofit an integration (e.g. betterleaks) into an existing gem
|
|
99
|
+
- `gempilot bump` — Bump version in `version.rb` (patch default, or minor/major/tiny/dev)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Remove the conflict markers and the stale `(patch default, or minor/major)` line. `grep -c "gempilot bump" CLAUDE.md` must print `1`.
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 4: Commit the merge**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git add CLAUDE.md
|
|
108
|
+
git commit -m "Merge master into betterleaks-jruby"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Expected: `git log --oneline master..HEAD | wc -l` prints `12` (11 branch commits + the merge); `git diff master -- CLAUDE.md issues.rec` shows only the `gempilot setup` bullet and the branch's Generated Gem Features additions, the two status flips, and the `F7C6A9AA` record.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### Task 3: Port three mechanics from the September spike
|
|
116
|
+
|
|
117
|
+
The branch's design stands. Three details verified in the September rework are strictly better and small enough to land in the same PR (verified 2026-09-23 on the rebased branch: gate `122 runs`, `205 examples`, `69 files`, all green):
|
|
118
|
+
|
|
119
|
+
1. `rake betterleaks` runs `betterleaks git` unconditionally, so in a gem scaffolded with `--no-git` it dies with `fatal: not a git repository`. Fall back to `betterleaks dir` outside a work tree, and pass `--no-banner`.
|
|
120
|
+
2. `secrets.yml` compiles betterleaks from source on every CI run (`actions/setup-go` + `go install ...@latest`, a floating version). Install the pinned 1.8.1 release tarball and verify it against the release's `checksums.txt` instead (download + checksum verified locally; the linux_x64 binary runs on GitHub's ubuntu runners).
|
|
121
|
+
3. The task spec only covers the "not installed" skip path. Add real-scan examples: a clean history passes, a randomly generated GitHub token fails the task, and a non-git directory scans without crashing.
|
|
122
|
+
|
|
123
|
+
**Files:**
|
|
124
|
+
- Modify: `lib/gempilot/betterleaks_task.rb`
|
|
125
|
+
- Modify: `data/templates/gem/dotfiles/github/workflows/secrets.yml`, `.github/workflows/secrets.yml`
|
|
126
|
+
- Test: `spec/gempilot/betterleaks_task_spec.rb`
|
|
127
|
+
|
|
128
|
+
**Interfaces:**
|
|
129
|
+
- Consumes: `Gempilot::BetterleaksTask` as defined on the branch (`task :betterleaks`, `warn_missing`).
|
|
130
|
+
- Produces: the same task name; `scan` chooses `git` or `dir` mode; both workflow files share one install step.
|
|
131
|
+
|
|
132
|
+
- [ ] **Step 1: Add the failing spec examples**
|
|
133
|
+
|
|
134
|
+
In `spec/gempilot/betterleaks_task_spec.rb` add `require "securerandom"` after `require "spec_helper"`, and before the file's final `end` add:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
describe "a real scan" do
|
|
138
|
+
around { |example| Dir.mktmpdir("betterleaks_task_spec") { |dir| Dir.chdir(dir) { example.run } } }
|
|
139
|
+
|
|
140
|
+
before { skip "betterleaks is not installed" unless system("betterleaks", "version", out: File::NULL) }
|
|
141
|
+
|
|
142
|
+
def commit_file(name, content)
|
|
143
|
+
system("git", "init", "--quiet", "-b", "main", ".")
|
|
144
|
+
system("git", "config", "user.email", "test@test.com")
|
|
145
|
+
system("git", "config", "user.name", "Test")
|
|
146
|
+
File.write(name, content)
|
|
147
|
+
system("git", "add", name)
|
|
148
|
+
system("git", "commit", "--quiet", "-m", "Add #{name}")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "passes on a history without secrets" do
|
|
152
|
+
commit_file("app.rb", "puts 'hi'\n")
|
|
153
|
+
expect { Rake::Task["betterleaks"].invoke }.not_to raise_error
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "fails when the history contains a secret" do
|
|
157
|
+
commit_file("config.rb", %(github_token = "ghp_#{SecureRandom.alphanumeric(36)}"\n))
|
|
158
|
+
expect { Rake::Task["betterleaks"].invoke }.to raise_error(RuntimeError, /betterleaks/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "scans the working tree when the gem is not a git repository" do
|
|
162
|
+
File.write("app.rb", "puts 'hi'\n")
|
|
163
|
+
expect { Rake::Task["betterleaks"].invoke }.not_to raise_error
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
(The token is random on purpose: betterleaks filters keyboard-walk "example" strings, so a fixed fake key is not detected.)
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 2: Run it to verify the last example fails**
|
|
171
|
+
|
|
172
|
+
Run: `bundle exec rspec spec/gempilot/betterleaks_task_spec.rb --no-color`
|
|
173
|
+
Expected: `5 examples, 1 failure` — "scans the working tree when the gem is not a git repository" raises `Command failed with status (1)` because `betterleaks git` errors outside a repository; the secret example already fails the task as expected.
|
|
174
|
+
|
|
175
|
+
- [ ] **Step 3: Choose the scan mode**
|
|
176
|
+
|
|
177
|
+
In `lib/gempilot/betterleaks_task.rb` replace `scan` and `betterleaks_available?` with:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
def scan
|
|
181
|
+
return warn_missing unless betterleaks_available?
|
|
182
|
+
|
|
183
|
+
sh "betterleaks", mode, "--no-banner", "--redact", "--verbose", "."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# git mode walks the whole commit history; outside a work tree (a gem
|
|
187
|
+
# scaffolded with --no-git) dir mode scans the files on disk instead of
|
|
188
|
+
# failing on the missing repository.
|
|
189
|
+
def mode
|
|
190
|
+
git_work_tree? ? "git" : "dir"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def git_work_tree?
|
|
194
|
+
system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def betterleaks_available?
|
|
198
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
199
|
+
binary = File.join(dir, "betterleaks")
|
|
200
|
+
File.file?(binary) && File.executable?(binary)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 4: Pin the CI install**
|
|
206
|
+
|
|
207
|
+
In both `data/templates/gem/dotfiles/github/workflows/secrets.yml` and `.github/workflows/secrets.yml`, replace the three steps `Set up Go`, `Install betterleaks`, `Scan for secrets` with:
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
- name: Install betterleaks
|
|
211
|
+
env:
|
|
212
|
+
BETTERLEAKS_VERSION: 1.8.1
|
|
213
|
+
run: |
|
|
214
|
+
cd "$(mktemp -d)"
|
|
215
|
+
base="https://github.com/betterleaks/betterleaks/releases/download/v${BETTERLEAKS_VERSION}"
|
|
216
|
+
asset="betterleaks_${BETTERLEAKS_VERSION}_linux_x64.tar.gz"
|
|
217
|
+
curl -sSfL -O "${base}/${asset}" -O "${base}/checksums.txt"
|
|
218
|
+
grep " ${asset}\$" checksums.txt | sha256sum --check
|
|
219
|
+
tar -xzf "${asset}" betterleaks
|
|
220
|
+
sudo install -m 0755 betterleaks /usr/local/bin/betterleaks
|
|
221
|
+
- name: Scan for secrets
|
|
222
|
+
run: betterleaks git --no-banner --redact --verbose
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The `checkout` step keeps `fetch-depth: 0`. No test references the Go steps (`grep -rn "go install\|setup-go" test spec` prints nothing).
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 5: Run the spec and rubocop, then commit**
|
|
228
|
+
|
|
229
|
+
Run: `bundle exec rspec spec/gempilot/betterleaks_task_spec.rb --no-color && bundle exec rubocop lib/gempilot/betterleaks_task.rb spec/gempilot/betterleaks_task_spec.rb`
|
|
230
|
+
Expected: `5 examples, 0 failures`; `2 files inspected, no offenses detected`.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
git add lib/gempilot/betterleaks_task.rb spec/gempilot/betterleaks_task_spec.rb data/templates/gem/dotfiles/github/workflows/secrets.yml .github/workflows/secrets.yml
|
|
234
|
+
git commit -m "Scan non-git gems in dir mode and pin the CI betterleaks install"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
### Task 4: Gate, push, PR
|
|
240
|
+
|
|
241
|
+
**Files:**
|
|
242
|
+
- None modified.
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 1: Full gate**
|
|
245
|
+
|
|
246
|
+
Run: `bundle exec rake default 2>&1 | tail -12`
|
|
247
|
+
Expected: minitest `122 runs, 0 failures`, RSpec `205 examples, 0 failures`, RuboCop `69 files inspected, no offenses detected`. (The branch's `rake betterleaks` is not part of `default`; with the binary absent its spec prints the install hint and passes.)
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 2: Smoke the retrofit and the scaffold**
|
|
250
|
+
|
|
251
|
+
From a scratch directory, with `<repo>` your checkout:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
<repo>/exe/gempilot create --author A --email a@b.c --summary s --ruby-version 4.0.6 --test minitest --no-exe --git --branch master --betterleaks hooked_gem
|
|
255
|
+
cd hooked_gem && git config core.hooksPath && ls -l .githooks/pre-commit .github/workflows/secrets.yml
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Expected: `.githooks`, an executable hook, and the workflow file. Then in any older generated gem, `<repo>/exe/gempilot setup betterleaks` twice: the second run reports `skip` for every step.
|
|
259
|
+
|
|
260
|
+
- [ ] **Step 3: Stop and hand off**
|
|
261
|
+
|
|
262
|
+
The plan ends here. Do not push. PR #21 ("Add betterleaks secret scanning + make dev gems JRuby-safe", opened 2026-07-26) already tracks this branch; the merge commit and the Task 3 commit are fast-forward additions to it, so a plain `git push origin betterleaks-jruby` by David updates the PR with no history rewrite. An agent executing this plan reports the green gate and leaves the push to him.
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# Multi-Gem RubyGems Push Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make `rake release:rubygems` push every gem in `pkg/` that belongs to the version being released (the plain gem plus any platform gems), and abort when there is none, instead of inheriting bundler's `release:rubygem_push`, which pushes exactly one file.
|
|
6
|
+
|
|
7
|
+
**Architecture:** gempilot takes ownership of `release:rubygem_push` the same way it already owns `release:source_control_push`: `ReleaseTasks` clears bundler's task and redefines it (still depending on `build`) to call a new `Gempilot::RubygemsRelease`, a small `StrictShell` collaborator in the style of `GithubRelease`/`Origin`. `RubygemsRelease` derives the package list from what `Project` already knows (`name`, `version_value`, `root`) with a version-scoped brace glob, so `my_gem-1.2.30.gem` and `my_gem-1.2.3.1.gem` left over in `pkg/` are never re-pushed. The prerequisite chain of `release:rubygems` (`build`, `release:guard_clean`, `release:source_control_push`, `release:rubygem_push`) is unchanged.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby 4.0, Rake (`Rake::Task#clear`, task prerequisites), `Pathname#glob` with `{a,b}` braces, RSpec with `instance_double` and `have_received(...).ordered`.
|
|
10
|
+
|
|
11
|
+
**Issue:** `F6C6EB04-8DA4-11F1-91EF-FA41C3164F3A` — "release:rubygem_push only handles one gem per version".
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Ruby `>= 4.0`; `it` block parameter is fine.
|
|
16
|
+
- Double-quoted strings (`Style/StringLiterals: double_quotes`); NO `# frozen_string_literal:` comments; trailing commas in multiline literals/arguments.
|
|
17
|
+
- rdoc doc blocks in canonical form (a bare `##` line, then `# ...` lines; never a comment line that reads like code — `Claude/NoCommentedCode`).
|
|
18
|
+
- Zeitwerk: new file `lib/gempilot/rubygems_release.rb` MUST define `Gempilot::RubygemsRelease` (`spec/zeitwerk_spec.rb` eager-loads gempilot).
|
|
19
|
+
- Out of scope, deliberately: bundler's `gem_push=no` environment switch and its `--key`/`--host` options are not reproduced (gempilot's `release:github` is the way to skip RubyGems; nothing in the templates sets `allowed_push_host`).
|
|
20
|
+
- `gem push` needs an interactive OTP prompt when `rubygems_mfa_required` is set; `StrictShell#sh` runs through `Kernel#system`, so stdin is inherited and the prompt works.
|
|
21
|
+
- Verification: `bundle exec rspec <files>` for focused runs, `bundle exec rubocop <files>` per task, `bundle exec rake default` for the full gate.
|
|
22
|
+
- Baseline (verified 2026-09-23): RSpec `192 examples, 0 failures`; RuboCop no offenses; minitest `109 runs, 4 failures` — the 4 are the Gemfile-template ordering regression fixed by Task 1 of `docs/superpowers/plans/2026-09-23-land-betterleaks-jruby.md`; apply that one-line change first for a fully green gate. Final state after this plan: RSpec `199 examples, 0 failures`, minitest unchanged.
|
|
23
|
+
- Commit messages: plain imperative sentences, no conventional-commit prefixes.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### Task 1: `Gempilot::RubygemsRelease`
|
|
28
|
+
|
|
29
|
+
**Files:**
|
|
30
|
+
- Create: `lib/gempilot/rubygems_release.rb`
|
|
31
|
+
- Test: `spec/gempilot/rubygems_release_spec.rb`
|
|
32
|
+
|
|
33
|
+
**Interfaces:**
|
|
34
|
+
- Consumes: `Gempilot::StrictShell#sh` (raises on non-zero exit), `Project#name`, `Project#version_value`, `Project#root` (Pathname).
|
|
35
|
+
- Produces: `Gempilot::RubygemsRelease.new(project) -> RubygemsRelease`, `#push -> void` (runs `gem push <path>` for each package in sorted order; raises `RuntimeError` "No packages for <name> <version> found in pkg/; run rake build first" when there is none). Task 2 relies on `RubygemsRelease.new(project).push`.
|
|
36
|
+
|
|
37
|
+
- [ ] **Step 1: Write the failing spec**
|
|
38
|
+
|
|
39
|
+
Create `spec/gempilot/rubygems_release_spec.rb`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "spec_helper"
|
|
43
|
+
|
|
44
|
+
RSpec.describe Gempilot::RubygemsRelease do
|
|
45
|
+
let(:project) { Gempilot::Project.new(Dir.pwd) }
|
|
46
|
+
let(:release) { described_class.new(project) }
|
|
47
|
+
let(:no_packages_error) { %r{No packages for my_gem 1\.2\.3 found in pkg/; run rake build first} }
|
|
48
|
+
|
|
49
|
+
around do |example|
|
|
50
|
+
Dir.mktmpdir("rubygems_release_spec") { |dir| Dir.chdir(dir) { example.run } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
before do
|
|
54
|
+
Pathname("lib/my_gem").mkpath
|
|
55
|
+
File.write "lib/my_gem.rb", "module MyGem; end\n"
|
|
56
|
+
File.write "lib/my_gem/version.rb", <<~RUBY
|
|
57
|
+
module MyGem
|
|
58
|
+
VERSION = "1.2.3".freeze
|
|
59
|
+
end
|
|
60
|
+
RUBY
|
|
61
|
+
allow(release).to receive(:sh)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def package(*names)
|
|
65
|
+
Pathname("pkg").mkpath
|
|
66
|
+
names.each { FileUtils.touch("pkg/#{it}") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pkg(name)
|
|
70
|
+
project.root.join("pkg", name).to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "#push" do
|
|
74
|
+
it "pushes the plain gem of the current version" do
|
|
75
|
+
package "my_gem-1.2.3.gem"
|
|
76
|
+
release.push
|
|
77
|
+
expect(release).to have_received(:sh).with("gem", "push", pkg("my_gem-1.2.3.gem"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "pushes every gem of the current version in sorted order", :aggregate_failures do
|
|
81
|
+
package "my_gem-1.2.3.gem", "my_gem-1.2.3-x86_64-linux.gem", "my_gem-1.2.3-arm64-darwin.gem"
|
|
82
|
+
release.push
|
|
83
|
+
expect(release).to have_received(:sh).with("gem", "push", pkg("my_gem-1.2.3-arm64-darwin.gem")).ordered
|
|
84
|
+
expect(release).to have_received(:sh).with("gem", "push", pkg("my_gem-1.2.3-x86_64-linux.gem")).ordered
|
|
85
|
+
expect(release).to have_received(:sh).with("gem", "push", pkg("my_gem-1.2.3.gem")).ordered
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context "when pkg also holds other versions and other gems" do
|
|
89
|
+
before { package "my_gem-1.2.3.gem", "my_gem-1.2.30.gem", "my_gem-1.2.3.1.gem", "other-1.2.3.gem" }
|
|
90
|
+
|
|
91
|
+
it "pushes only the gems of the current version", :aggregate_failures do
|
|
92
|
+
release.push
|
|
93
|
+
expect(release).to have_received(:sh).with("gem", "push", pkg("my_gem-1.2.3.gem"))
|
|
94
|
+
expect(release).to have_received(:sh).once
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises without pushing when pkg holds no gem of the current version", :aggregate_failures do
|
|
99
|
+
package "my_gem-1.2.30.gem"
|
|
100
|
+
expect { release.push }.to raise_error(RuntimeError, no_packages_error)
|
|
101
|
+
expect(release).not_to have_received(:sh)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "raises when pkg does not exist" do
|
|
105
|
+
expect { release.push }.to raise_error(RuntimeError, no_packages_error)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
(The error regex is a named `let` because `Claude/MysteryRegex` rejects long inline regexes.)
|
|
112
|
+
|
|
113
|
+
- [ ] **Step 2: Run it to verify it fails**
|
|
114
|
+
|
|
115
|
+
Run: `bundle exec rspec spec/gempilot/rubygems_release_spec.rb --no-color`
|
|
116
|
+
Expected: FAIL to load — `NameError: uninitialized constant Gempilot::RubygemsRelease`.
|
|
117
|
+
|
|
118
|
+
- [ ] **Step 3: Create the class**
|
|
119
|
+
|
|
120
|
+
Create `lib/gempilot/rubygems_release.rb`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
module Gempilot
|
|
124
|
+
##
|
|
125
|
+
# Pushes every gem of the version being released to RubyGems. Backs the
|
|
126
|
+
# +release:rubygem_push+ task in place of bundler's, which pushes only the
|
|
127
|
+
# single gem its own +build+ task produced. A project that builds several
|
|
128
|
+
# gems per version (platform gems carrying a compiled executable alongside
|
|
129
|
+
# the plain ruby gem) needs all of them pushed, so this globs +pkg/+ for the
|
|
130
|
+
# packages of that version: <tt>name-version.gem</tt> and
|
|
131
|
+
# <tt>name-version-platform.gem</tt>. The glob is scoped to the exact version
|
|
132
|
+
# because +pkg/+ is never emptied between releases; a looser
|
|
133
|
+
# <tt>name-version*.gem</tt> would also sweep up <tt>name-1.2.30.gem</tt> and
|
|
134
|
+
# <tt>name-1.2.3.1.gem</tt> and re-push versions already on RubyGems. Finding
|
|
135
|
+
# no package at all aborts the release, since a release that published
|
|
136
|
+
# nothing must not report success.
|
|
137
|
+
class RubygemsRelease
|
|
138
|
+
include StrictShell
|
|
139
|
+
|
|
140
|
+
attr_reader :project
|
|
141
|
+
|
|
142
|
+
def initialize(project)
|
|
143
|
+
@project = project
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def push
|
|
147
|
+
paths = packages
|
|
148
|
+
raise "No packages for #{name_and_version} found in pkg/; run rake build first" if paths.empty?
|
|
149
|
+
|
|
150
|
+
paths.each { sh "gem", "push", it.to_s }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def name_and_version
|
|
156
|
+
"#{project.name} #{project.version_value}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def packages
|
|
160
|
+
stem = "#{project.name}-#{project.version_value}"
|
|
161
|
+
project.root.glob("pkg/{#{stem}.gem,#{stem}-*.gem}").sort
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`Pathname#glob` delegates to `Dir.glob`, which expands `{a,b}` braces by default (verified: for stem `foo-0.3.0` the pattern matches `foo-0.3.0.gem` and `foo-0.3.0-x86_64-linux.gem` and not `foo-0.3.10.gem` or `foo-0.3.0.1.gem`). `Project#root` is a `Pathname`, so the results are absolute `Pathname`s; `sh` gets them as strings. Keep the `.sort`: `Dir.glob` returns the first brace alternative's matches before the second's, so without it the plain gem would come first and the sorted-order example would fail. A missing `pkg/` directory simply globs to `[]`, which is why "raises when pkg does not exist" needs no extra code.
|
|
168
|
+
|
|
169
|
+
- [ ] **Step 4: Run the spec and rubocop**
|
|
170
|
+
|
|
171
|
+
Run: `bundle exec rspec spec/gempilot/rubygems_release_spec.rb spec/zeitwerk_spec.rb --no-color && bundle exec rubocop lib/gempilot/rubygems_release.rb spec/gempilot/rubygems_release_spec.rb`
|
|
172
|
+
Expected: `6 examples, 0 failures`; `2 files inspected, no offenses detected`.
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 5: Commit**
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
git add lib/gempilot/rubygems_release.rb spec/gempilot/rubygems_release_spec.rb
|
|
178
|
+
git commit -m "Add RubygemsRelease to push every gem of a version"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### Task 2: Own `release:rubygem_push`
|
|
184
|
+
|
|
185
|
+
**Files:**
|
|
186
|
+
- Modify: `lib/gempilot/release_tasks.rb`
|
|
187
|
+
- Test: `spec/gempilot/version_task_spec.rb`
|
|
188
|
+
|
|
189
|
+
**Interfaces:**
|
|
190
|
+
- Consumes: `RubygemsRelease.new(project).push` (Task 1), `ReleaseTasks#clear_task`, Rake DSL `task`.
|
|
191
|
+
- Produces: task `release:rubygem_push` with prerequisites exactly `["build"]`, whose action is `RubygemsRelease.new(project).push`; bundler's action for that task is cleared. `release:rubygems`'s prerequisite chain is unchanged.
|
|
192
|
+
|
|
193
|
+
- [ ] **Step 1: Add the failing examples**
|
|
194
|
+
|
|
195
|
+
In `spec/gempilot/version_task_spec.rb`, inside `describe "release task hierarchy"`, after the example "builds release:rubygems from bundler's own tasks", add:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
it "builds before pushing to RubyGems" do
|
|
199
|
+
expect(Rake::Task["release:rubygem_push"].prerequisites).to eq(["build"])
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Inside `describe "release task behavior"`, extend the doubles:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
let(:origin) { instance_double(Gempilot::Origin, push: nil) }
|
|
207
|
+
let(:github) { instance_double(Gempilot::GithubRelease, create: nil, destroy: nil, list: nil) }
|
|
208
|
+
let(:rubygems) { instance_double(Gempilot::RubygemsRelease, push: nil) }
|
|
209
|
+
|
|
210
|
+
before do
|
|
211
|
+
allow(Gempilot::Origin).to receive(:new).and_return(origin)
|
|
212
|
+
allow(Gempilot::GithubRelease).to receive(:new).and_return(github)
|
|
213
|
+
allow(Gempilot::RubygemsRelease).to receive(:new).and_return(rubygems)
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
and, after the example "release:source_control_push pushes via Origin", add:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
it "release:rubygem_push pushes every package via RubygemsRelease" do
|
|
221
|
+
Rake::Task["release:rubygem_push"].execute
|
|
222
|
+
expect(rubygems).to have_received(:push)
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
(`execute` rather than `invoke`: this spec does not load `bundler/gem_tasks`, so the `build` prerequisite does not exist in its Rake application; `execute` runs the action without prerequisites.)
|
|
227
|
+
|
|
228
|
+
- [ ] **Step 2: Run to verify the new examples fail**
|
|
229
|
+
|
|
230
|
+
Run: `bundle exec rspec spec/gempilot/version_task_spec.rb --no-color`
|
|
231
|
+
Expected: `16 examples, 2 failures` — "builds before pushing to RubyGems" fails with `Don't know how to build task 'release:rubygem_push'` (no such task without bundler), and the behavior example fails the same way.
|
|
232
|
+
|
|
233
|
+
- [ ] **Step 3: Define the task in ReleaseTasks**
|
|
234
|
+
|
|
235
|
+
In `lib/gempilot/release_tasks.rb`, replace the module doc block and `define_release_tasks`, and add `define_rubygem_push` right after `override_source_control_push`:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
module Gempilot
|
|
239
|
+
##
|
|
240
|
+
# Rake task definitions for publishing a release to RubyGems and GitHub.
|
|
241
|
+
# Mixed into VersionTask. Assumes +bundler/gem_tasks+ has been required so the
|
|
242
|
+
# +build+ and +release:guard_clean+ tasks exist (the generated Rakefile
|
|
243
|
+
# guarantees this). Replaces two of bundler's release steps: its
|
|
244
|
+
# +already_tagged?+-guarded +release:source_control_push+ with an idempotent
|
|
245
|
+
# push, and its single-gem +release:rubygem_push+ with RubygemsRelease, which
|
|
246
|
+
# pushes every gem in +pkg/+ built for the released version.
|
|
247
|
+
module ReleaseTasks
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def define_release_tasks(project)
|
|
251
|
+
override_source_control_push(project)
|
|
252
|
+
define_rubygem_push(project)
|
|
253
|
+
define_release_namespace(project)
|
|
254
|
+
define_root_release_task
|
|
255
|
+
define_unrelease_tasks(project)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def override_source_control_push(project)
|
|
259
|
+
clear_task "release:source_control_push"
|
|
260
|
+
task("release:source_control_push") { Origin.new(project.version_tag).push }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def define_rubygem_push(project)
|
|
264
|
+
clear_task "release:rubygem_push"
|
|
265
|
+
task("release:rubygem_push" => "build") { RubygemsRelease.new(project).push }
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Everything from `define_release_namespace` down is unchanged. `Rake::Task#clear` drops bundler's prerequisites and action but keeps the task registered, so redefining it with `=> "build"` yields the same single prerequisite the spec pins.
|
|
270
|
+
|
|
271
|
+
- [ ] **Step 4: Run the spec and rubocop, then the full gate**
|
|
272
|
+
|
|
273
|
+
Run: `bundle exec rspec spec/gempilot/version_task_spec.rb spec/gempilot/rubygems_release_spec.rb --no-color && bundle exec rubocop lib/gempilot/release_tasks.rb spec/gempilot/version_task_spec.rb`
|
|
274
|
+
Expected: `21 examples, 0 failures`; `2 files inspected, no offenses detected`.
|
|
275
|
+
|
|
276
|
+
Run: `bundle exec rake default 2>&1 | tail -8`
|
|
277
|
+
Expected: RSpec `199 examples, 0 failures`, RuboCop no offenses, minitest as in the baseline.
|
|
278
|
+
|
|
279
|
+
- [ ] **Step 5: Commit**
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
git add lib/gempilot/release_tasks.rb spec/gempilot/version_task_spec.rb
|
|
283
|
+
git commit -m "Push every gem of the released version to RubyGems"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
### Task 3: Documentation
|
|
289
|
+
|
|
290
|
+
**Files:**
|
|
291
|
+
- Modify: `README.md` (Version Management Tasks table)
|
|
292
|
+
- Modify: `CLAUDE.md` (Generated Gem Features, publishing bullet)
|
|
293
|
+
|
|
294
|
+
**Interfaces:**
|
|
295
|
+
- Consumes: final behaviour of Tasks 1–2. No code.
|
|
296
|
+
|
|
297
|
+
- [ ] **Step 1: README**
|
|
298
|
+
|
|
299
|
+
Change the `rake release:rubygems` row to:
|
|
300
|
+
|
|
301
|
+
```markdown
|
|
302
|
+
| `rake release:rubygems` | Build, then push every gem in `pkg/` for the current version to RubyGems |
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
- [ ] **Step 2: CLAUDE.md**
|
|
306
|
+
|
|
307
|
+
Replace the publishing bullet (the one starting "Publishing rake tasks") with:
|
|
308
|
+
|
|
309
|
+
```markdown
|
|
310
|
+
- Publishing rake tasks (mixed into `VersionTask` via `Gempilot::ReleaseTasks`): `release` (all remotes), `release:rubygems`, `release:github`, `release:list:github`, `unrelease`, `unrelease:github`. These override and reuse bundler's `bundler/gem_tasks` release chain; `release:source_control_push` is replaced with `Gempilot::Origin` to push commit+tag idempotently, and `release:rubygem_push` is owned by gempilot (`Gempilot::RubygemsRelease`) and pushes every `pkg/<name>-<version>{,-*}.gem` rather than bundler's single built gem
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- [ ] **Step 3: Commit**
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
git add README.md CLAUDE.md
|
|
317
|
+
git commit -m "Document the version-scoped RubyGems push"
|
|
318
|
+
```
|