gempilot 0.2.3 → 0.2.4
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/.rubocop.yml +0 -1
- data/CLAUDE.md +2 -1
- data/README.md +8 -4
- data/data/templates/gem/dotfiles/rubocop.yml.erb +1 -12
- data/docs/superpowers/plans/2026-07-20-release-task-hierarchy.md +583 -0
- data/docs/superpowers/specs/2026-07-20-release-task-hierarchy-design.md +229 -0
- data/issues.rec +47 -3
- data/lib/gempilot/github_release.rb +0 -2
- data/lib/gempilot/origin.rb +52 -0
- data/lib/gempilot/release_tasks.rb +73 -0
- data/lib/gempilot/version.rb +1 -1
- data/lib/gempilot/version_task.rb +3 -16
- data/vendor/vendored.gemv +0 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed510959d35011586d23fc0dcc0baff3a453a2acbd1a2d1c8fb4489cee63e6e0
|
|
4
|
+
data.tar.gz: 455ba876df7b3fb40df26932737cae4062a6e6e2210f1fd02b18a231566bd23d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ce5185ea5c07adc72435dee47b2f749f4f6146f26a09c8d8c557490e4be1b69b2b048e4c9ab32b8548d043c9a0d393cc32a0d8dd23c94c98cabd3a8737334d9
|
|
7
|
+
data.tar.gz: 6094227a45e9bb173f0455dc155bd3ac7593698a7c41c7ac1482c5e7c44a59a90c547bb20206d3c7887b87a84977fc1f7ca615b2975fe53dc6a8ab85384a83e8
|
data/.rubocop.yml
CHANGED
data/CLAUDE.md
CHANGED
|
@@ -35,4 +35,5 @@ A CLI tool for creating and managing Ruby gems, built on CommandKit.
|
|
|
35
35
|
- RuboCop with framework-specific plugins
|
|
36
36
|
- GitHub Actions CI workflow (`.github/workflows/ci.yml`)
|
|
37
37
|
- `git ls-files`-based gemspec with glob fallback for non-git repos
|
|
38
|
-
- Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert
|
|
38
|
+
- Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert` and composite `version:release`/`version:unrelease`
|
|
39
|
+
- 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
|
data/README.md
CHANGED
|
@@ -86,7 +86,8 @@ gempilot bump major
|
|
|
86
86
|
|
|
87
87
|
### `gempilot release`
|
|
88
88
|
|
|
89
|
-
Delegates to `rake release
|
|
89
|
+
Delegates to `rake release`, which publishes the current version to all remotes
|
|
90
|
+
(RubyGems + GitHub).
|
|
90
91
|
|
|
91
92
|
### `gempilot console`
|
|
92
93
|
|
|
@@ -120,9 +121,12 @@ Generated gems include rake tasks for the full version lifecycle:
|
|
|
120
121
|
| `rake version:revert` | Revert the last version bump commit |
|
|
121
122
|
| `rake version:release` | Bump, commit, and tag (combined) |
|
|
122
123
|
| `rake version:unrelease` | Untag and reset (combined) |
|
|
123
|
-
| `rake
|
|
124
|
-
| `rake
|
|
125
|
-
| `rake
|
|
124
|
+
| `rake release` | Publish the current version to all remotes (RubyGems + GitHub) |
|
|
125
|
+
| `rake release:rubygems` | Build and push the gem to RubyGems |
|
|
126
|
+
| `rake release:github` | Push commit + tag, then create the GitHub release |
|
|
127
|
+
| `rake release:list:github` | List GitHub releases |
|
|
128
|
+
| `rake unrelease` | Delete the release from all remotes that support it |
|
|
129
|
+
| `rake unrelease:github` | Delete the GitHub release and remote tag |
|
|
126
130
|
|
|
127
131
|
## Development
|
|
128
132
|
|
|
@@ -25,12 +25,6 @@ AllCops:
|
|
|
25
25
|
Exclude:
|
|
26
26
|
- bin/*
|
|
27
27
|
- vendor/**/*
|
|
28
|
-
- lib/core_ext/**/*
|
|
29
|
-
- rakelib/project.rb
|
|
30
|
-
- rakelib/project_version.rb
|
|
31
|
-
- rakelib/version_tag.rb
|
|
32
|
-
- rakelib/github_release.rb
|
|
33
|
-
- rakelib/strict_shell.rb
|
|
34
28
|
<% if @hyphenated -%>
|
|
35
29
|
- lib/<%= @gem_name %>.rb
|
|
36
30
|
<% end -%>
|
|
@@ -124,17 +118,12 @@ Metrics/BlockLength:
|
|
|
124
118
|
<% end -%>
|
|
125
119
|
Exclude:
|
|
126
120
|
- "<%= @gem_name %>.gemspec"
|
|
127
|
-
- "rakelib/**/*"
|
|
128
121
|
|
|
129
|
-
# Gemspec
|
|
122
|
+
# Gemspec uses patterns that trigger Claude cops legitimately.
|
|
130
123
|
Claude/NoFancyUnicode:
|
|
131
124
|
Exclude:
|
|
132
125
|
- "<%= @gem_name %>.gemspec"
|
|
133
126
|
|
|
134
|
-
Claude/MysteryRegex:
|
|
135
|
-
Exclude:
|
|
136
|
-
- "rakelib/**/*"
|
|
137
|
-
|
|
138
127
|
# Zeitwerk loaders are mutable by design — only flag literal mutable values.
|
|
139
128
|
Style/MutableConstant:
|
|
140
129
|
EnforcedStyle: literals
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# Release Task Hierarchy 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:** Move GitHub publishing out of the `version:github:*` namespace into the idiomatic `release` / `unrelease` namespace, and fix GitHub releasing (bundler's `already_tagged?` guard silently skips the git push).
|
|
6
|
+
|
|
7
|
+
**Architecture:** A new `Gempilot::Origin` object does an idempotent commit+tag push and replaces bundler's buggy `release:source_control_push`. RubyGems publishing reuses bundler's own `build` / `release:guard_clean` / `release:rubygem_push` leaf tasks via prerequisites; GitHub publishing uses the existing (slimmed) `GithubRelease`. All task wiring lives in a new `Gempilot::ReleaseTasks` module mixed into `VersionTask`. Bare `rake release` is a pure prerequisite composite over both remotes — no arguments, no dispatcher code.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Rake (`Rake::TaskLib`, `bundler/gem_tasks`), Zeitwerk autoloading, RSpec (unit specs) + Minitest (generator tests), RuboCop (`rubocop-claude`/`-rspec`/`-performance`/`-rake`/`-design`).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-07-20-release-task-hierarchy-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Preconditions
|
|
16
|
+
|
|
17
|
+
- Branch `release-task-hierarchy` is checked out.
|
|
18
|
+
- `bundle install` works and the baseline is green:
|
|
19
|
+
- `bundle exec rake spec` → `106 examples, 0 failures`
|
|
20
|
+
- `bundle exec rake test` → `108 runs, 0 failures`
|
|
21
|
+
- `bundle exec rubocop` → `no offenses detected`
|
|
22
|
+
- If `bundle install` fails with `Bundler::Plugin::Index::SourceConflict (Source(s) 'vault' ...)`, align the local plugin index to the installed vault plugin version, then reinstall:
|
|
23
|
+
```bash
|
|
24
|
+
ruby -e 'f=".bundle/plugin/index"; File.write(f, File.read(f).gsub("bundler-source-vault-0.1.2","bundler-source-vault-0.1.5"))'
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
(`.bundle/` is not tracked by git — this is a local-environment fix only.)
|
|
28
|
+
|
|
29
|
+
## Final target — task interface
|
|
30
|
+
|
|
31
|
+
| Task | Behavior |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `rake release` | Publish current version to all remotes (RubyGems + GitHub) |
|
|
34
|
+
| `rake release:rubygems` | Build + push the gem to RubyGems |
|
|
35
|
+
| `rake release:github` | Push commit + tag, then create the GitHub release |
|
|
36
|
+
| `rake release:list:github` | List GitHub releases |
|
|
37
|
+
| `rake unrelease` | Delete the release from all remotes that support it (GitHub) |
|
|
38
|
+
| `rake unrelease:github` | Delete the GitHub release and remote tag |
|
|
39
|
+
|
|
40
|
+
`version:github:release/unrelease/list` are removed. `version:*` lifecycle tasks and the `version:release`/`version:unrelease` composites are unchanged.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Task 1: `Gempilot::Origin` (idempotent commit + tag push)
|
|
45
|
+
|
|
46
|
+
**Files:**
|
|
47
|
+
- Create: `lib/gempilot/origin.rb`
|
|
48
|
+
- Test: `spec/gempilot/origin_spec.rb`
|
|
49
|
+
|
|
50
|
+
This is the bug fix: an always-push, no-guard replacement for bundler's `release:source_control_push`. Mirrors bundler's `git_push` (push the branch ref, then the tag ref, to the branch's configured remote — defaulting to `origin`).
|
|
51
|
+
|
|
52
|
+
- [ ] **Step 1: Write the failing spec**
|
|
53
|
+
|
|
54
|
+
Create `spec/gempilot/origin_spec.rb`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require "spec_helper"
|
|
58
|
+
|
|
59
|
+
RSpec.describe Gempilot::Origin do
|
|
60
|
+
subject(:origin) { described_class.new("v1.2.3") }
|
|
61
|
+
|
|
62
|
+
around do |example|
|
|
63
|
+
Dir.mktmpdir("origin_spec") { |dir| Dir.chdir(dir) { example.run } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
before do
|
|
67
|
+
system("git", "init", "--quiet", "-b", "main", ".")
|
|
68
|
+
system("git", "config", "user.email", "test@test.com")
|
|
69
|
+
system("git", "config", "user.name", "Test")
|
|
70
|
+
system("git", "commit", "--allow-empty", "--quiet", "-m", "init")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "#push" do
|
|
74
|
+
before { allow(origin).to receive(:sh) }
|
|
75
|
+
|
|
76
|
+
it "pushes the current branch, then the tag, to the resolved remote", :aggregate_failures do
|
|
77
|
+
origin.push
|
|
78
|
+
expect(origin).to have_received(:sh).with("git", "push", "origin", "refs/heads/main").ordered
|
|
79
|
+
expect(origin).to have_received(:sh).with("git", "push", "origin", "refs/tags/v1.2.3").ordered
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe "#push against a real remote" do
|
|
84
|
+
before do
|
|
85
|
+
system("git", "clone", "--quiet", "--bare", ".", "origin.git")
|
|
86
|
+
system("git", "remote", "add", "origin", "origin.git")
|
|
87
|
+
system("git", "tag", "v1.2.3")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "lands the tag on the remote and stays idempotent on re-run", :aggregate_failures do
|
|
91
|
+
origin.push
|
|
92
|
+
expect(`git --git-dir=origin.git tag`.strip).to eq("v1.2.3")
|
|
93
|
+
expect { described_class.new("v1.2.3").push }.not_to raise_error
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 2: Run the spec, verify it fails**
|
|
100
|
+
|
|
101
|
+
Run: `bundle exec rspec spec/gempilot/origin_spec.rb`
|
|
102
|
+
Expected: FAIL — `uninitialized constant Gempilot::Origin` (or `NameError`).
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 3: Implement `Gempilot::Origin`**
|
|
105
|
+
|
|
106
|
+
Create `lib/gempilot/origin.rb`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
require "open3"
|
|
110
|
+
|
|
111
|
+
module Gempilot
|
|
112
|
+
## Pushes the current branch and a release tag to the branch's git remote.
|
|
113
|
+
## Backs the +release:source_control_push+ task. Idempotent: pushing an
|
|
114
|
+
## already-pushed branch or tag is a no-op, so re-running a release never
|
|
115
|
+
## fails on an existing tag (unlike bundler's +already_tagged?+ guard, which
|
|
116
|
+
## skips the push entirely once the tag exists locally).
|
|
117
|
+
class Origin
|
|
118
|
+
include StrictShell
|
|
119
|
+
|
|
120
|
+
attr_reader :tag
|
|
121
|
+
|
|
122
|
+
def initialize(tag)
|
|
123
|
+
@tag = tag
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def push
|
|
127
|
+
sh "git", "push", remote, "refs/heads/#{branch}"
|
|
128
|
+
sh "git", "push", remote, "refs/tags/#{tag}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def branch
|
|
134
|
+
@branch ||= capture("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def remote
|
|
138
|
+
@remote ||= configured_remote || "origin"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def configured_remote
|
|
142
|
+
out, status = Open3.capture2("git", "config", "--get", "branch.#{branch}.remote")
|
|
143
|
+
out.strip if status.success?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def capture(*args)
|
|
147
|
+
out, status = Open3.capture2(*args)
|
|
148
|
+
raise "Command #{args.join(" ").inspect} failed" unless status.success?
|
|
149
|
+
|
|
150
|
+
out.strip
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 4: Run the spec, verify it passes**
|
|
157
|
+
|
|
158
|
+
Run: `bundle exec rspec spec/gempilot/origin_spec.rb`
|
|
159
|
+
Expected: PASS — `3 examples, 0 failures` (2 `#push` examples across the two describe blocks). RuboCop next.
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 5: Lint the new files**
|
|
162
|
+
|
|
163
|
+
Run: `bundle exec rubocop lib/gempilot/origin.rb spec/gempilot/origin_spec.rb`
|
|
164
|
+
Expected: `no offenses detected`. If any offense appears, fix it and re-run.
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 6: Commit**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
git add lib/gempilot/origin.rb spec/gempilot/origin_spec.rb
|
|
170
|
+
git commit -m "Add Gempilot::Origin for idempotent commit+tag push"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Task 2: Slim `GithubRelease#create`
|
|
176
|
+
|
|
177
|
+
**Files:**
|
|
178
|
+
- Modify: `lib/gempilot/github_release.rb`
|
|
179
|
+
- Test: `spec/gempilot/github_release_spec.rb`
|
|
180
|
+
|
|
181
|
+
Pushing is now `Origin`'s job (the `release:source_control_push` prerequisite). `GithubRelease#create` should only call `gh release create`.
|
|
182
|
+
|
|
183
|
+
- [ ] **Step 1: Update the spec to drop the git-push expectations**
|
|
184
|
+
|
|
185
|
+
In `spec/gempilot/github_release_spec.rb`, replace the entire `describe "#create"` block:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
describe "#create" do
|
|
189
|
+
it "creates a release with generated notes" do
|
|
190
|
+
release.create
|
|
191
|
+
args = ["gh", "release", "create", "--generate-notes", "--fail-on-no-commits", tag]
|
|
192
|
+
expect(release).to have_received(:sh).with(*args)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
(Leave the `#destroy` and `#list` describe blocks unchanged.)
|
|
198
|
+
|
|
199
|
+
- [ ] **Step 2: Run the spec, verify `#create` now fails**
|
|
200
|
+
|
|
201
|
+
Run: `bundle exec rspec spec/gempilot/github_release_spec.rb`
|
|
202
|
+
Expected: The `#create` example passes already (it only asserts the `gh` call), but confirm the whole file is green. If green, the current `create` still issues `git push` calls harmlessly. To make the test *drive* the change, also assert no git push:
|
|
203
|
+
|
|
204
|
+
Add this example inside `describe "#create"`:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
it "does not push git refs itself" do
|
|
208
|
+
release.create
|
|
209
|
+
expect(release).not_to have_received(:sh).with("git", any_args)
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Run again: `bundle exec rspec spec/gempilot/github_release_spec.rb`
|
|
214
|
+
Expected: FAIL — `does not push git refs itself` fails because `create` still runs `git push`.
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 3: Remove the git push from `create`**
|
|
217
|
+
|
|
218
|
+
In `lib/gempilot/github_release.rb`, change `create` from:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
def create
|
|
222
|
+
sh "git", "push"
|
|
223
|
+
sh "git", "push", "--tags"
|
|
224
|
+
sh "gh", "release", "create",
|
|
225
|
+
"--generate-notes", "--fail-on-no-commits",
|
|
226
|
+
tag
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
to:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
def create
|
|
234
|
+
sh "gh", "release", "create",
|
|
235
|
+
"--generate-notes", "--fail-on-no-commits",
|
|
236
|
+
tag
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- [ ] **Step 4: Run the spec, verify it passes**
|
|
241
|
+
|
|
242
|
+
Run: `bundle exec rspec spec/gempilot/github_release_spec.rb`
|
|
243
|
+
Expected: PASS — all examples green.
|
|
244
|
+
|
|
245
|
+
- [ ] **Step 5: Lint**
|
|
246
|
+
|
|
247
|
+
Run: `bundle exec rubocop lib/gempilot/github_release.rb spec/gempilot/github_release_spec.rb`
|
|
248
|
+
Expected: `no offenses detected`.
|
|
249
|
+
|
|
250
|
+
- [ ] **Step 6: Commit**
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
git add lib/gempilot/github_release.rb spec/gempilot/github_release_spec.rb
|
|
254
|
+
git commit -m "Slim GithubRelease#create: push is now Origin's job"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Task 3: `ReleaseTasks` module + rewire `VersionTask`
|
|
260
|
+
|
|
261
|
+
**Files:**
|
|
262
|
+
- Create: `lib/gempilot/release_tasks.rb`
|
|
263
|
+
- Modify: `lib/gempilot/version_task.rb`
|
|
264
|
+
- Test: `spec/gempilot/version_task_spec.rb`
|
|
265
|
+
|
|
266
|
+
The new `release`/`unrelease` task tree, extracted into a module (keeps `VersionTask` under `Metrics/ClassLength`) and `include`d into `VersionTask`.
|
|
267
|
+
|
|
268
|
+
- [ ] **Step 1: Write the failing spec additions**
|
|
269
|
+
|
|
270
|
+
In `spec/gempilot/version_task_spec.rb`, add these two `describe` blocks inside the top-level `RSpec.describe Gempilot::VersionTask do ... end` (e.g. after the existing `describe "version:release"` block, before the final `end`):
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
describe "release task hierarchy" do
|
|
274
|
+
it "defines the release and unrelease tasks", :aggregate_failures do
|
|
275
|
+
%w[release release:rubygems release:github release:list:github unrelease unrelease:github].each do |name|
|
|
276
|
+
expect(Rake::Task).to be_task_defined(name)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "removes the old version:github tasks", :aggregate_failures do
|
|
281
|
+
%w[version:github:release version:github:unrelease version:github:list].each do |name|
|
|
282
|
+
expect(Rake::Task).not_to be_task_defined(name)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it "composes release from the per-remote tasks" do
|
|
287
|
+
expect(Rake::Task["release"].prerequisites).to eq(%w[release:rubygems release:github])
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "builds release:rubygems from bundler's own tasks" do
|
|
291
|
+
chain = %w[build release:guard_clean release:source_control_push release:rubygem_push]
|
|
292
|
+
expect(Rake::Task["release:rubygems"].prerequisites).to eq(chain)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "composes unrelease from the github task" do
|
|
296
|
+
expect(Rake::Task["unrelease"].prerequisites).to eq(%w[unrelease:github])
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe "release task behavior" do
|
|
301
|
+
let(:origin) { instance_double(Gempilot::Origin, push: nil) }
|
|
302
|
+
let(:github) { instance_double(Gempilot::GithubRelease, create: nil, destroy: nil, list: nil) }
|
|
303
|
+
|
|
304
|
+
before do
|
|
305
|
+
allow(Gempilot::Origin).to receive(:new).and_return(origin)
|
|
306
|
+
allow(Gempilot::GithubRelease).to receive(:new).and_return(github)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "release:source_control_push pushes via Origin" do
|
|
310
|
+
Rake::Task["release:source_control_push"].invoke
|
|
311
|
+
expect(origin).to have_received(:push)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "release:github pushes, then creates the release", :aggregate_failures do
|
|
315
|
+
Rake::Task["release:github"].invoke
|
|
316
|
+
expect(origin).to have_received(:push)
|
|
317
|
+
expect(github).to have_received(:create)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it "release:list:github lists releases" do
|
|
321
|
+
Rake::Task["release:list:github"].invoke
|
|
322
|
+
expect(github).to have_received(:list)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it "unrelease:github destroys the release" do
|
|
326
|
+
Rake::Task["unrelease:github"].invoke
|
|
327
|
+
expect(github).to have_received(:destroy)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- [ ] **Step 2: Run the spec, verify it fails**
|
|
333
|
+
|
|
334
|
+
Run: `bundle exec rspec spec/gempilot/version_task_spec.rb`
|
|
335
|
+
Expected: FAIL — `release`/`release:github`/etc. are not defined; several examples error with "Don't know how to build task 'release:github'" / `be_task_defined` returns false.
|
|
336
|
+
|
|
337
|
+
- [ ] **Step 3: Create the `ReleaseTasks` module**
|
|
338
|
+
|
|
339
|
+
Create `lib/gempilot/release_tasks.rb`:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
module Gempilot
|
|
343
|
+
## Rake task definitions for publishing a release to RubyGems and GitHub.
|
|
344
|
+
## Mixed into VersionTask. Assumes +bundler/gem_tasks+ has been required so the
|
|
345
|
+
## +build+, +release:guard_clean+, and +release:rubygem_push+ tasks exist (the
|
|
346
|
+
## generated Rakefile guarantees this). Fixes GitHub releasing by replacing
|
|
347
|
+
## bundler's +already_tagged?+-guarded +release:source_control_push+ with an
|
|
348
|
+
## idempotent push.
|
|
349
|
+
module ReleaseTasks
|
|
350
|
+
private
|
|
351
|
+
|
|
352
|
+
def define_release_tasks(project)
|
|
353
|
+
override_source_control_push(project)
|
|
354
|
+
define_release_namespace(project)
|
|
355
|
+
define_root_release_task
|
|
356
|
+
define_unrelease_tasks(project)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def override_source_control_push(project)
|
|
360
|
+
clear_task "release:source_control_push"
|
|
361
|
+
task("release:source_control_push") { Origin.new(project.version_tag).push }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def define_release_namespace(project)
|
|
365
|
+
namespace :release do
|
|
366
|
+
define_rubygems_release
|
|
367
|
+
define_github_release(project)
|
|
368
|
+
define_release_list(project)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def define_rubygems_release
|
|
373
|
+
desc "Release the current version to RubyGems"
|
|
374
|
+
task rubygems: %w[build release:guard_clean release:source_control_push release:rubygem_push]
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def define_github_release(project)
|
|
378
|
+
desc "Create a GitHub release for the current version"
|
|
379
|
+
task github: "release:source_control_push" do
|
|
380
|
+
GithubRelease.new(project.version_tag).create
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def define_release_list(project)
|
|
385
|
+
namespace :list do
|
|
386
|
+
desc "List GitHub releases"
|
|
387
|
+
task(:github) { GithubRelease.new(project.version_tag).list }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def define_root_release_task
|
|
392
|
+
clear_task "release"
|
|
393
|
+
desc "Release the current version to all remotes"
|
|
394
|
+
task release: %w[release:rubygems release:github]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def define_unrelease_tasks(project)
|
|
398
|
+
desc "Delete the current release from all remotes that support it"
|
|
399
|
+
task unrelease: %w[unrelease:github]
|
|
400
|
+
define_unrelease_namespace(project)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def define_unrelease_namespace(project)
|
|
404
|
+
namespace :unrelease do
|
|
405
|
+
desc "Delete the GitHub release for the current version"
|
|
406
|
+
task(:github) { GithubRelease.new(project.version_tag).destroy }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def clear_task(name)
|
|
411
|
+
Rake::Task[name].clear if Rake::Task.task_defined?(name)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 4: Rewire `VersionTask` to use the module**
|
|
418
|
+
|
|
419
|
+
In `lib/gempilot/version_task.rb`:
|
|
420
|
+
|
|
421
|
+
First, add the include at the top of the class body (right after `class VersionTask < Rake::TaskLib`):
|
|
422
|
+
|
|
423
|
+
```ruby
|
|
424
|
+
class VersionTask < Rake::TaskLib
|
|
425
|
+
include ReleaseTasks
|
|
426
|
+
|
|
427
|
+
attr_reader :project
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Then change `define_tasks` from:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
def define_tasks
|
|
434
|
+
define_version_tasks
|
|
435
|
+
define_version_composite_tasks
|
|
436
|
+
define_github_tasks
|
|
437
|
+
end
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
to:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
def define_tasks
|
|
444
|
+
define_version_tasks
|
|
445
|
+
define_version_composite_tasks
|
|
446
|
+
define_release_tasks(@project)
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Then delete the entire `define_github_tasks` method (the `namespace "version:github" do ... end` method) — from `def define_github_tasks` through its closing `end`.
|
|
451
|
+
|
|
452
|
+
- [ ] **Step 5: Run the version_task spec, verify it passes**
|
|
453
|
+
|
|
454
|
+
Run: `bundle exec rspec spec/gempilot/version_task_spec.rb`
|
|
455
|
+
Expected: PASS — all examples green (existing `version:bump`/`version:release` plus the new hierarchy/behavior examples).
|
|
456
|
+
|
|
457
|
+
- [ ] **Step 6: Run the full suite + lint**
|
|
458
|
+
|
|
459
|
+
Run: `bundle exec rake spec && bundle exec rake test && bundle exec rubocop`
|
|
460
|
+
Expected: `rake spec` green, `rake test` green, RuboCop `no offenses detected`. In particular confirm `lib/gempilot/version_task.rb` reports no `Metrics/ClassLength` offense.
|
|
461
|
+
|
|
462
|
+
- [ ] **Step 7: Sanity-check the task tree renders and old tasks are gone**
|
|
463
|
+
|
|
464
|
+
Run (from the repo root — gempilot dogfoods its own `VersionTask`):
|
|
465
|
+
```bash
|
|
466
|
+
bundle exec rake -T release ; bundle exec rake -T unrelease ; bundle exec rake -AT | grep -c 'version:github' || true
|
|
467
|
+
```
|
|
468
|
+
Expected: `release`, `release:rubygems`, `release:github`, `release:list:github`, `unrelease`, `unrelease:github` appear; the `version:github` grep count is `0`.
|
|
469
|
+
|
|
470
|
+
- [ ] **Step 8: Commit**
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
git add lib/gempilot/release_tasks.rb lib/gempilot/version_task.rb spec/gempilot/version_task_spec.rb
|
|
474
|
+
git commit -m "Move GitHub publishing into idiomatic release/unrelease namespace"
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Task 4: Update docs
|
|
480
|
+
|
|
481
|
+
**Files:**
|
|
482
|
+
- Modify: `README.md`
|
|
483
|
+
- Modify: `CLAUDE.md`
|
|
484
|
+
|
|
485
|
+
No tests — documentation only. Make the exact edits below.
|
|
486
|
+
|
|
487
|
+
- [ ] **Step 1: Update the README `gempilot release` note**
|
|
488
|
+
|
|
489
|
+
In `README.md`, replace:
|
|
490
|
+
|
|
491
|
+
```markdown
|
|
492
|
+
Delegates to `rake release` to build and push the gem.
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
with:
|
|
496
|
+
|
|
497
|
+
```markdown
|
|
498
|
+
Delegates to `rake release`, which publishes the current version to all remotes
|
|
499
|
+
(RubyGems + GitHub).
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
- [ ] **Step 2: Update the README task table**
|
|
503
|
+
|
|
504
|
+
In `README.md`, replace these three rows:
|
|
505
|
+
|
|
506
|
+
```markdown
|
|
507
|
+
| `rake version:github:release` | Push and create a GitHub release |
|
|
508
|
+
| `rake version:github:unrelease` | Delete the GitHub release |
|
|
509
|
+
| `rake version:github:list` | List GitHub releases |
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
with:
|
|
513
|
+
|
|
514
|
+
```markdown
|
|
515
|
+
| `rake release` | Publish the current version to all remotes (RubyGems + GitHub) |
|
|
516
|
+
| `rake release:rubygems` | Build and push the gem to RubyGems |
|
|
517
|
+
| `rake release:github` | Push commit + tag, then create the GitHub release |
|
|
518
|
+
| `rake release:list:github` | List GitHub releases |
|
|
519
|
+
| `rake unrelease` | Delete the release from all remotes that support it |
|
|
520
|
+
| `rake unrelease:github` | Delete the GitHub release and remote tag |
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
(The `rake version:release` and `rake version:unrelease` rows stay.)
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 3: Update CLAUDE.md**
|
|
526
|
+
|
|
527
|
+
In `CLAUDE.md`, replace this bullet:
|
|
528
|
+
|
|
529
|
+
```markdown
|
|
530
|
+
- Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert`, composite `version:release`/`version:unrelease`, and `version:github:release/unrelease/list`
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
with:
|
|
534
|
+
|
|
535
|
+
```markdown
|
|
536
|
+
- Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert` and composite `version:release`/`version:unrelease`
|
|
537
|
+
- 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
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
- [ ] **Step 4: Verify no stale references remain**
|
|
541
|
+
|
|
542
|
+
Run: `grep -rn "version:github" README.md CLAUDE.md`
|
|
543
|
+
Expected: no output.
|
|
544
|
+
|
|
545
|
+
- [ ] **Step 5: Commit**
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
git add README.md CLAUDE.md docs/superpowers/specs/2026-07-20-release-task-hierarchy-design.md
|
|
549
|
+
git commit -m "Docs: document release/unrelease task hierarchy"
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Task 5: Full verification
|
|
555
|
+
|
|
556
|
+
**Files:** none (verification only).
|
|
557
|
+
|
|
558
|
+
- [ ] **Step 1: Run the complete default suite**
|
|
559
|
+
|
|
560
|
+
Run: `bundle exec rake`
|
|
561
|
+
Expected: `task default: [:test, :spec, :rubocop]` runs all three — Minitest green (`0 failures`), RSpec green (`0 failures`), RuboCop `no offenses detected`.
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 2: Verify Zeitwerk naming for the new files**
|
|
564
|
+
|
|
565
|
+
Run: `bundle exec rake zeitwerk:validate`
|
|
566
|
+
Expected: `Zeitwerk: All files loaded successfully.` (confirms `lib/gempilot/origin.rb` → `Gempilot::Origin` and `lib/gempilot/release_tasks.rb` → `Gempilot::ReleaseTasks` load cleanly under eager load).
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 3: Confirm the working tree is clean and review the branch**
|
|
569
|
+
|
|
570
|
+
Run: `git status` and `git log --oneline master..HEAD`
|
|
571
|
+
Expected: clean working tree; commits for Origin, GithubRelease slim, release/unrelease move, and docs.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Self-review notes (traceability to spec)
|
|
576
|
+
|
|
577
|
+
- **Task hierarchy** (spec §"Task interface"/"Task definitions") → Task 3 (module + rewire) + Task 3 Step 1 structure specs.
|
|
578
|
+
- **Bug fix — idempotent push replacing `already_tagged?`** (spec §"Problem"/`Origin`) → Task 1 (`Origin`, incl. real-remote idempotency regression test) + Task 3 `override_source_control_push`.
|
|
579
|
+
- **`GithubRelease` slimmed** (spec §"Components") → Task 2.
|
|
580
|
+
- **RubyGems reuses bundler leaf tasks** (spec §"Decisions") → Task 3 `define_rubygems_release` + prerequisite spec.
|
|
581
|
+
- **`unrelease:rubygems` errors naturally** (spec §"Error handling") → covered by not defining it; documented, no code.
|
|
582
|
+
- **Docs** (spec §"Docs") → Task 4.
|
|
583
|
+
- **`Rakefile.erb` unchanged** (spec §"Out of scope") → no task; load order already correct.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Release task hierarchy — design
|
|
2
|
+
|
|
3
|
+
**Issue:** `2C8EF3C8-83E9-11F1-9256-FE6CB9572C2F` — "gh release tasks need adjusting in task hierarchy"
|
|
4
|
+
|
|
5
|
+
**Date:** 2026-07-20
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Gempilot installs GitHub release tasks under a bespoke `version:github:*` namespace
|
|
10
|
+
(`version:github:release/unrelease/list`). Two things are wrong:
|
|
11
|
+
|
|
12
|
+
1. **Wrong hierarchy.** GitHub publishing should live in the standard `release` /
|
|
13
|
+
`unrelease` namespace alongside RubyGems publishing, not in a custom
|
|
14
|
+
`version:github:*` corner.
|
|
15
|
+
2. **GitHub releasing is broken.** The release flow bumps + commits + tags *locally*
|
|
16
|
+
via `version:release` (`version:tag` runs `git tag vX`). When the actual publish
|
|
17
|
+
runs through bundler's `release` task, bundler's `already_tagged?` guard sees the
|
|
18
|
+
tag already exists locally, prints *"Tag vX has already been created."*, and
|
|
19
|
+
**skips `release:source_control_push` entirely** — so the commit and tag never
|
|
20
|
+
reach the remote, and `gh release create vX` has nothing to attach to. This is the
|
|
21
|
+
"tags have already been pushed / doesn't work at all" symptom in the issue.
|
|
22
|
+
|
|
23
|
+
This is an approved **breaking change**. No backwards compatibility for the old
|
|
24
|
+
`version:github:*` tasks.
|
|
25
|
+
|
|
26
|
+
## Decisions
|
|
27
|
+
|
|
28
|
+
- **Namespace-only interface. No `[remote]` argument.** Target selection lives in the
|
|
29
|
+
task name (`release:github`), not an argument. This is the idiomatic Rake convention
|
|
30
|
+
(task name = what to do; argument = a value), and it avoids a direct collision:
|
|
31
|
+
`bundler/gem_tasks` already defines `task "release", [:remote]` where `remote` means
|
|
32
|
+
the *git remote name* (`origin`) passed to `git push`. Overloading that same word to
|
|
33
|
+
mean "publish target" is exactly what made the original `release[remote]` idea
|
|
34
|
+
confusing. So the final interface uses no argument at all.
|
|
35
|
+
- **Bare `rake release` publishes to all remotes** (RubyGems + GitHub), expressed as a
|
|
36
|
+
pure prerequisite composite — no dispatcher code.
|
|
37
|
+
- **Reuse bundler's building blocks for RubyGems.** `release:rubygems` composes
|
|
38
|
+
bundler's existing `build`, `release:guard_clean`, and `release:rubygem_push` leaf
|
|
39
|
+
tasks via prerequisites. No new object wraps them — this preserves `allowed_push_host`
|
|
40
|
+
(private gem servers), MFA/OTP prompts, and `gem_push=no`.
|
|
41
|
+
- **Fix the bug by overriding the one task that is wrong.** Replace bundler's
|
|
42
|
+
`release:source_control_push` with an idempotent push (no `already_tagged?` guard),
|
|
43
|
+
done the same idiomatic way bundler does it: an explicit `git push <remote> <branch>`
|
|
44
|
+
then `git push <remote> <tag>`. Both remotes share this single task as a prerequisite,
|
|
45
|
+
so both get the fix and the push runs exactly once per release.
|
|
46
|
+
- **Keep the local version lifecycle as-is.** `version:release` / `version:unrelease`
|
|
47
|
+
(local bump → commit → tag) are unchanged. The `version:` namespace and the top-level
|
|
48
|
+
`release` do not collide in Rake.
|
|
49
|
+
|
|
50
|
+
## Task interface
|
|
51
|
+
|
|
52
|
+
| Task | Behavior |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `rake release` | Publish current version to all remotes (RubyGems + GitHub) |
|
|
55
|
+
| `rake release:rubygems` | Build + push the gem to RubyGems |
|
|
56
|
+
| `rake release:github` | Push commit + tag, then create the GitHub release |
|
|
57
|
+
| `rake release:list:github` | List GitHub releases |
|
|
58
|
+
| `rake unrelease` | Delete the release from all remotes that support it (GitHub) |
|
|
59
|
+
| `rake unrelease:github` | Delete the GitHub release and remote tag |
|
|
60
|
+
|
|
61
|
+
`rake unrelease:rubygems` is intentionally undefined — Rake fails with "Don't know how
|
|
62
|
+
to build task", which is the correct signal that RubyGems yanking is out of scope.
|
|
63
|
+
|
|
64
|
+
## Task definitions
|
|
65
|
+
|
|
66
|
+
Defined in `Gempilot::VersionTask`, replacing the current `define_github_tasks`. Runs
|
|
67
|
+
after `require "bundler/gem_tasks"` (guaranteed by the generated `Rakefile` and by
|
|
68
|
+
gempilot's own `Rakefile`), so bundler's tasks exist when we override them. Each
|
|
69
|
+
override is guarded with `task_defined?` so the definitions also work when bundler's
|
|
70
|
+
gem tasks are absent (e.g. gempilot's own spec suite).
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# THE FIX: idempotent push, replacing bundler's already_tagged?-guarded task.
|
|
74
|
+
Rake::Task["release:source_control_push"].clear if Rake::Task.task_defined?("release:source_control_push")
|
|
75
|
+
task "release:source_control_push" do
|
|
76
|
+
Origin.new(project.version_tag).push
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
namespace :release do
|
|
80
|
+
desc "Release the current version to RubyGems"
|
|
81
|
+
task rubygems: %w[build release:guard_clean release:source_control_push release:rubygem_push]
|
|
82
|
+
|
|
83
|
+
desc "Create a GitHub release for the current version"
|
|
84
|
+
task github: "release:source_control_push" do
|
|
85
|
+
GithubRelease.new(project.version_tag).create
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
namespace :list do
|
|
89
|
+
desc "List GitHub releases"
|
|
90
|
+
task(:github) { GithubRelease.new(project.version_tag).list }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Rake::Task["release"].clear if Rake::Task.task_defined?("release")
|
|
95
|
+
desc "Release the current version to all remotes"
|
|
96
|
+
task release: %w[release:rubygems release:github]
|
|
97
|
+
|
|
98
|
+
desc "Delete the current release from all remotes that support it"
|
|
99
|
+
task unrelease: %w[unrelease:github]
|
|
100
|
+
|
|
101
|
+
namespace :unrelease do
|
|
102
|
+
desc "Delete the GitHub release for the current version"
|
|
103
|
+
task(:github) { GithubRelease.new(project.version_tag).destroy }
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Components
|
|
108
|
+
|
|
109
|
+
### `Gempilot::Origin` (new — `lib/gempilot/origin.rb`)
|
|
110
|
+
|
|
111
|
+
A small domain object (same style as the existing `GithubRelease` / `VersionTag`) that
|
|
112
|
+
pushes the current branch and a given tag to the branch's configured git remote. This
|
|
113
|
+
backs `release:source_control_push` and is the bug fix.
|
|
114
|
+
|
|
115
|
+
- `initialize(tag)` — the version tag string (e.g. `"v1.0.0"`).
|
|
116
|
+
- `#push` — runs, via `StrictShell#sh`:
|
|
117
|
+
- `git push <remote> refs/heads/<branch>`
|
|
118
|
+
- `git push <remote> refs/tags/<tag>`
|
|
119
|
+
- `<branch>` = `git rev-parse --abbrev-ref HEAD`; `<remote>` = the branch's configured
|
|
120
|
+
remote (`git config --get branch.<branch>.remote`), defaulting to `origin` — mirroring
|
|
121
|
+
bundler's own `current_branch` / `default_remote` logic.
|
|
122
|
+
- Idempotent: pushing an already-pushed branch/tag exits 0 ("Everything up-to-date"),
|
|
123
|
+
so re-running a release never fails on the tag. This is the property bundler's guard
|
|
124
|
+
broke.
|
|
125
|
+
|
|
126
|
+
### `Gempilot::GithubRelease` (modified — `lib/gempilot/github_release.rb`)
|
|
127
|
+
|
|
128
|
+
- `#create` drops its two internal `git push` / `git push --tags` lines. Pushing is now
|
|
129
|
+
`Origin`'s job (the `release:source_control_push` prerequisite runs first). `create`
|
|
130
|
+
becomes purely: `gh release create --generate-notes --fail-on-no-commits <tag>`.
|
|
131
|
+
- `#destroy` and `#list` are unchanged.
|
|
132
|
+
|
|
133
|
+
### `Gempilot::ReleaseTasks` (new — `lib/gempilot/release_tasks.rb`)
|
|
134
|
+
|
|
135
|
+
The release / unrelease task definitions above, extracted into a focused module that is
|
|
136
|
+
`include`d into `VersionTask`. This is a separate concern from the local version
|
|
137
|
+
lifecycle, and extracting it keeps `VersionTask` under RuboCop's `Metrics/ClassLength`
|
|
138
|
+
(the combined class would otherwise exceed the 100-line limit). All methods are private
|
|
139
|
+
and parameterized by `project` (no reliance on the host's ivars beyond the single
|
|
140
|
+
`define_release_tasks(project)` entry point).
|
|
141
|
+
|
|
142
|
+
### `Gempilot::VersionTask` (modified — `lib/gempilot/version_task.rb`)
|
|
143
|
+
|
|
144
|
+
- `include ReleaseTasks`.
|
|
145
|
+
- Remove `define_github_tasks`; replace its call in `define_tasks` with
|
|
146
|
+
`define_release_tasks(@project)`.
|
|
147
|
+
- `version:*` local lifecycle tasks and `version:release` / `version:unrelease`
|
|
148
|
+
composites are untouched.
|
|
149
|
+
|
|
150
|
+
There is **no** `RubygemsRelease` object. RubyGems publishing is bundler-task
|
|
151
|
+
composition only.
|
|
152
|
+
|
|
153
|
+
## Data flow
|
|
154
|
+
|
|
155
|
+
- `rake release:github` → `release:source_control_push` (push commit + tag) →
|
|
156
|
+
`gh release create`.
|
|
157
|
+
- `rake release:rubygems` → `build` → `release:guard_clean` →
|
|
158
|
+
`release:source_control_push` (push commit + tag) → `release:rubygem_push` (gem push).
|
|
159
|
+
- `rake release` → `release:rubygems` (which pushes during its chain), then
|
|
160
|
+
`release:github`. Rake invokes `release:source_control_push` once, so the git push
|
|
161
|
+
happens a single time, before both publishes.
|
|
162
|
+
- `rake unrelease` → `unrelease:github` → `gh release delete --yes --cleanup-tag`
|
|
163
|
+
(removes the GitHub release and the remote tag).
|
|
164
|
+
|
|
165
|
+
## Error handling
|
|
166
|
+
|
|
167
|
+
- **Unsupported unrelease target** (`rake unrelease:rubygems`): undefined task → Rake's
|
|
168
|
+
native "Don't know how to build task" error. No custom code.
|
|
169
|
+
- **Shell failures**: every git / gh / gem command runs through `StrictShell#sh`, which
|
|
170
|
+
already raises on non-zero exit.
|
|
171
|
+
- **Tag / commit not pushable**: if the local tag does not exist, the `git push
|
|
172
|
+
refs/tags/<tag>` fails loudly via `StrictShell` — the correct signal to run
|
|
173
|
+
`version:release` first. No separate guard task is added (deliberate: the
|
|
174
|
+
`version:release` flow is responsible for producing a clean, tagged commit; adding a
|
|
175
|
+
bundler-independent `guard_clean` is out of scope).
|
|
176
|
+
|
|
177
|
+
## Testing (TDD)
|
|
178
|
+
|
|
179
|
+
Written before implementation.
|
|
180
|
+
|
|
181
|
+
**`spec/gempilot/origin_spec.rb` (new)**
|
|
182
|
+
- `#push` issues `git push <remote> <branch>` then `git push <remote> <tag>` in order
|
|
183
|
+
(stub `sh`, assert ordered calls; assert branch/remote resolution).
|
|
184
|
+
- **Regression / integration**: in a tmpdir, create a real repo with a bare "origin"
|
|
185
|
+
remote and a tag, run `Origin#push`, assert the commit + tag land on origin; run it a
|
|
186
|
+
**second** time and assert it still succeeds (idempotent) — directly proving the bug
|
|
187
|
+
(bundler's guarded skip) is fixed.
|
|
188
|
+
|
|
189
|
+
**`spec/gempilot/github_release_spec.rb` (modified)**
|
|
190
|
+
- `#create` no longer calls `git push` / `git push --tags`; it calls only
|
|
191
|
+
`gh release create --generate-notes --fail-on-no-commits <tag>`.
|
|
192
|
+
- `#destroy` and `#list` specs unchanged.
|
|
193
|
+
|
|
194
|
+
**`spec/gempilot/version_task_spec.rb` (extended)**
|
|
195
|
+
- **Structure**: `release`, `release:rubygems`, `release:github`, `release:list:github`,
|
|
196
|
+
`unrelease`, `unrelease:github` are defined; `version:github:release`,
|
|
197
|
+
`version:github:unrelease`, `version:github:list` are **not**.
|
|
198
|
+
- **Composites by prerequisite** (no invocation needed): `release` prerequisites ==
|
|
199
|
+
`%w[release:rubygems release:github]`; `release:rubygems` prerequisites ==
|
|
200
|
+
`%w[build release:guard_clean release:source_control_push release:rubygem_push]`;
|
|
201
|
+
`unrelease` prerequisites == `%w[unrelease:github]`.
|
|
202
|
+
- **Behavior by invocation** (stub `Origin` / `GithubRelease` constructors):
|
|
203
|
+
- `release:source_control_push` invokes `Origin#push`.
|
|
204
|
+
- `release:github` runs the push, then `GithubRelease#create`.
|
|
205
|
+
- `release:list:github` invokes `GithubRelease#list`.
|
|
206
|
+
- `unrelease:github` invokes `GithubRelease#destroy`.
|
|
207
|
+
- `release:rubygems` is asserted by prerequisites only (its bundler prerequisites are
|
|
208
|
+
absent in the spec's fresh Rake app, so it is not invoked there).
|
|
209
|
+
|
|
210
|
+
## Docs
|
|
211
|
+
|
|
212
|
+
- `README.md` — replace the three `version:github:*` rows in the tasks table with the
|
|
213
|
+
new `release` / `unrelease` tasks; update the `gempilot release` note to reflect that
|
|
214
|
+
bare `rake release` now publishes to all remotes.
|
|
215
|
+
- `CLAUDE.md` — update the "Version lifecycle rake tasks" bullet: drop
|
|
216
|
+
`version:github:release/unrelease/list`, add `release` / `release:rubygems` /
|
|
217
|
+
`release:github` / `release:list:github` / `unrelease` / `unrelease:github`.
|
|
218
|
+
|
|
219
|
+
## Out of scope
|
|
220
|
+
|
|
221
|
+
- `data/templates/gem/Rakefile.erb` needs no change — it already requires
|
|
222
|
+
`bundler/gem_tasks` before `Gempilot::VersionTask.new`, which the override relies on.
|
|
223
|
+
- The stale `rakelib/*` excludes in `data/templates/gem/dotfiles/rubocop.yml.erb`
|
|
224
|
+
(including `rakelib/github_release.rb`) belong to a separate open issue about internal
|
|
225
|
+
concerns leaking to gem users (`B45C988A`). Not touched here.
|
|
226
|
+
- RubyGems yanking (`unrelease:rubygems`).
|
|
227
|
+
- The `gempilot release` CLI command keeps proxying to `rake release`; its behavior
|
|
228
|
+
changes only because bare `rake release` now targets all remotes.
|
|
229
|
+
- Any change to the local `version:*` lifecycle tasks.
|
data/issues.rec
CHANGED
|
@@ -101,7 +101,7 @@ Id: 0411D4DA-4323-11F1-847C-FE6CB9572C2F
|
|
|
101
101
|
Updated: Tue, 28 Apr 2026 12:55:09 -0400
|
|
102
102
|
Title: rubocop config should not exclude rakelib
|
|
103
103
|
Description: it is supplied by gempilot. Should not be eliminating author files
|
|
104
|
-
Status:
|
|
104
|
+
Status: closed
|
|
105
105
|
|
|
106
106
|
Id: C72CD130-4324-11F1-ACAE-FE6CB9572C2F
|
|
107
107
|
Updated: Tue, 28 Apr 2026 13:07:46 -0400
|
|
@@ -164,7 +164,51 @@ Description: Has to do with how the ENV var RM_INFO is being interpreted
|
|
|
164
164
|
Status: open
|
|
165
165
|
|
|
166
166
|
Id: 8D847572-7273-11F1-B508-FE6CB9572C2E
|
|
167
|
-
Updated:
|
|
167
|
+
Updated: Fri, 03 Jul 2026 22:38:22 +0000
|
|
168
168
|
Title: Gem extensions are broken
|
|
169
169
|
Description: when using gempilot on a gem extension, like 'foo-support', using idiomatic ruby patterns for dir structure, gempilot Project class throws an error
|
|
170
|
-
Status:
|
|
170
|
+
Status: closed
|
|
171
|
+
|
|
172
|
+
Id: B45C988A-83C3-11F1-A32B-FE6CB9572C2F
|
|
173
|
+
Updated: Sun, 19 Jul 2026 18:46:39 -0400
|
|
174
|
+
Title: Gempilot internal concerns leak to gemusers
|
|
175
|
+
Description: Gempilot fresh install leaks the following in .rubocop.yml:
|
|
176
|
+
+
|
|
177
|
+
+ AllCops:
|
|
178
|
+
+ 1 NewCops: enable
|
|
179
|
+
+ 2 TargetRubyVersion: 4.0
|
|
180
|
+
+ 3 Exclude:
|
|
181
|
+
+ 4 - bin/*
|
|
182
|
+
+ 5 - vendor/**/*
|
|
183
|
+
+ 6 - lib/core_ext/**/*
|
|
184
|
+
+ 7 - rakelib/project.rb
|
|
185
|
+
+ 8 - rakelib/project_version.rb
|
|
186
|
+
+ 9
|
|
187
|
+
Status: closed
|
|
188
|
+
|
|
189
|
+
Id: 2C8EF3C8-83E9-11F1-9256-FE6CB9572C2F
|
|
190
|
+
Updated: Sun, 19 Jul 2026 23:14:52 -0400
|
|
191
|
+
Title: gh release tasks need adjusting in task heirarchy
|
|
192
|
+
Description: the github release related tasks are invoked as follows:
|
|
193
|
+
+ ```
|
|
194
|
+
+ rake version:github:release
|
|
195
|
+
+ rake version:github:list
|
|
196
|
+
+ rake version:github:unrelease
|
|
197
|
+
+
|
|
198
|
+
+ ```
|
|
199
|
+
+ they should instead be under the main idiomatic release namespace:
|
|
200
|
+
+
|
|
201
|
+
+ ```
|
|
202
|
+
+ rake release[remote] # release to the remote, e.g. rubygems/github
|
|
203
|
+
+ rake release:github # alternative to rake release[github]
|
|
204
|
+
+ rake unrelease[remote] # currently only supported for github, not rubygems
|
|
205
|
+
+ rake unrelease:github # alternative to rake unrelease[github]
|
|
206
|
+
+ rake release:list:github # list GitHub releases
|
|
207
|
+
+ rake release:
|
|
208
|
+
+ ```
|
|
209
|
+
+ where remote is "github"
|
|
210
|
+
+
|
|
211
|
+
+ Note that this is a breaking change, and it is approved. Do not provide backwards compatibility for the old tasks. Move forward
|
|
212
|
+
+
|
|
213
|
+
+ Also, there is an issue that prevents github releasing from working at all via the old tasks. I get an error every time saying that "tags have already been pushed" or something along those lines. Ensure that is fixed as well
|
|
214
|
+
Status: closed
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Gempilot
|
|
4
|
+
## Pushes the current branch and a release tag to the branch's git remote.
|
|
5
|
+
## Backs the +release:source_control_push+ task. Idempotent: pushing an
|
|
6
|
+
## already-pushed branch or tag is a no-op, so re-running a release never
|
|
7
|
+
## fails on an existing tag (unlike bundler's +already_tagged?+ guard, which
|
|
8
|
+
## skips the push entirely once the tag exists locally).
|
|
9
|
+
class Origin
|
|
10
|
+
include StrictShell
|
|
11
|
+
|
|
12
|
+
attr_reader :tag
|
|
13
|
+
|
|
14
|
+
def initialize(tag)
|
|
15
|
+
@tag = tag
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def push
|
|
19
|
+
sh "git", "push", remote, "refs/heads/#{branch}"
|
|
20
|
+
sh "git", "push", remote, "refs/tags/#{tag}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def branch
|
|
26
|
+
@branch ||= resolve_branch
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_branch
|
|
30
|
+
name = capture("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
31
|
+
raise "Cannot push from a detached HEAD; check out a branch first" if name == "HEAD"
|
|
32
|
+
|
|
33
|
+
name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remote
|
|
37
|
+
@remote ||= configured_remote || "origin"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def configured_remote
|
|
41
|
+
out, status = Open3.capture2("git", "config", "--get", "branch.#{branch}.remote")
|
|
42
|
+
out.strip if status.success?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def capture(*args)
|
|
46
|
+
out, status = Open3.capture2(*args)
|
|
47
|
+
raise "Command #{args.join(" ").inspect} failed (exit #{status.exitstatus})" unless status.success?
|
|
48
|
+
|
|
49
|
+
out.strip
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Gempilot
|
|
2
|
+
## Rake task definitions for publishing a release to RubyGems and GitHub.
|
|
3
|
+
## Mixed into VersionTask. Assumes +bundler/gem_tasks+ has been required so the
|
|
4
|
+
## +build+, +release:guard_clean+, and +release:rubygem_push+ tasks exist (the
|
|
5
|
+
## generated Rakefile guarantees this). Fixes GitHub releasing by replacing
|
|
6
|
+
## bundler's +already_tagged?+-guarded +release:source_control_push+ with an
|
|
7
|
+
## idempotent push.
|
|
8
|
+
module ReleaseTasks
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def define_release_tasks(project)
|
|
12
|
+
override_source_control_push(project)
|
|
13
|
+
define_release_namespace(project)
|
|
14
|
+
define_root_release_task
|
|
15
|
+
define_unrelease_tasks(project)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def override_source_control_push(project)
|
|
19
|
+
clear_task "release:source_control_push"
|
|
20
|
+
task("release:source_control_push") { Origin.new(project.version_tag).push }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def define_release_namespace(project)
|
|
24
|
+
namespace :release do
|
|
25
|
+
define_rubygems_release
|
|
26
|
+
define_github_release(project)
|
|
27
|
+
define_release_list(project)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def define_rubygems_release
|
|
32
|
+
desc "Release the current version to RubyGems"
|
|
33
|
+
task rubygems: %w[build release:guard_clean release:source_control_push release:rubygem_push]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def define_github_release(project)
|
|
37
|
+
desc "Create a GitHub release for the current version"
|
|
38
|
+
task github: "release:source_control_push" do
|
|
39
|
+
GithubRelease.new(project.version_tag).create
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def define_release_list(project)
|
|
44
|
+
namespace :list do
|
|
45
|
+
desc "List GitHub releases"
|
|
46
|
+
task(:github) { GithubRelease.new(project.version_tag).list }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define_root_release_task
|
|
51
|
+
clear_task "release"
|
|
52
|
+
desc "Release the current version to all remotes"
|
|
53
|
+
task release: %w[release:rubygems release:github]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def define_unrelease_tasks(project)
|
|
57
|
+
desc "Delete the current release from all remotes that support it"
|
|
58
|
+
task unrelease: %w[unrelease:github]
|
|
59
|
+
define_unrelease_namespace(project)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def define_unrelease_namespace(project)
|
|
63
|
+
namespace :unrelease do
|
|
64
|
+
desc "Delete the GitHub release for the current version"
|
|
65
|
+
task(:github) { GithubRelease.new(project.version_tag).destroy }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear_task(name)
|
|
70
|
+
Rake::Task[name].clear if Rake::Task.task_defined?(name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/gempilot/version.rb
CHANGED
|
@@ -4,6 +4,8 @@ require_relative "../gempilot"
|
|
|
4
4
|
module Gempilot
|
|
5
5
|
## Rake tasks for version lifecycle management.
|
|
6
6
|
class VersionTask < Rake::TaskLib
|
|
7
|
+
include ReleaseTasks
|
|
8
|
+
|
|
7
9
|
attr_reader :project
|
|
8
10
|
|
|
9
11
|
def initialize(root: Dir.pwd)
|
|
@@ -17,7 +19,7 @@ module Gempilot
|
|
|
17
19
|
def define_tasks
|
|
18
20
|
define_version_tasks
|
|
19
21
|
define_version_composite_tasks
|
|
20
|
-
|
|
22
|
+
define_release_tasks(@project)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def define_version_tasks
|
|
@@ -89,20 +91,5 @@ module Gempilot
|
|
|
89
91
|
project.refresh_version!
|
|
90
92
|
end
|
|
91
93
|
end
|
|
92
|
-
|
|
93
|
-
def define_github_tasks
|
|
94
|
-
project = @project
|
|
95
|
-
|
|
96
|
-
namespace "version:github" do
|
|
97
|
-
desc "Create a GitHub release for the current version"
|
|
98
|
-
task(:release) { GithubRelease.new(project.version_tag).create }
|
|
99
|
-
|
|
100
|
-
desc "Delete the GitHub release for the current version"
|
|
101
|
-
task(:unrelease) { GithubRelease.new(project.version_tag).destroy }
|
|
102
|
-
|
|
103
|
-
desc "List GitHub releases"
|
|
104
|
-
task(:list) { GithubRelease.new(project.version_tag).list }
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
94
|
end
|
|
108
95
|
end
|
data/vendor/vendored.gemv
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gempilot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Gillis
|
|
@@ -106,6 +106,8 @@ files:
|
|
|
106
106
|
- data/templates/gem/test/zeitwerk_test.rb.erb
|
|
107
107
|
- data/templates/new/.keep
|
|
108
108
|
- data/templates/new/command.rb.erb
|
|
109
|
+
- docs/superpowers/plans/2026-07-20-release-task-hierarchy.md
|
|
110
|
+
- docs/superpowers/specs/2026-07-20-release-task-hierarchy-design.md
|
|
109
111
|
- exe/gempilot
|
|
110
112
|
- issues.rec
|
|
111
113
|
- lib/core_ext/string/inflection_methods.rb
|
|
@@ -124,8 +126,10 @@ files:
|
|
|
124
126
|
- lib/gempilot/cli/generator.rb
|
|
125
127
|
- lib/gempilot/gem_constant.rb
|
|
126
128
|
- lib/gempilot/github_release.rb
|
|
129
|
+
- lib/gempilot/origin.rb
|
|
127
130
|
- lib/gempilot/project.rb
|
|
128
131
|
- lib/gempilot/project/version.rb
|
|
132
|
+
- lib/gempilot/release_tasks.rb
|
|
129
133
|
- lib/gempilot/strict_shell.rb
|
|
130
134
|
- lib/gempilot/version.rb
|
|
131
135
|
- lib/gempilot/version_tag.rb
|
|
@@ -151,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
151
155
|
- !ruby/object:Gem::Version
|
|
152
156
|
version: '0'
|
|
153
157
|
requirements: []
|
|
154
|
-
rubygems_version: 4.0.
|
|
158
|
+
rubygems_version: 4.0.16
|
|
155
159
|
specification_version: 4
|
|
156
160
|
summary: A toolkit for creating, managing, and releasing your own rubygems
|
|
157
161
|
test_files: []
|