gem_kit-release 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/lib/gem_kit/release/changelog.rb +313 -0
- data/lib/gem_kit/release/cli/bump.rb +133 -0
- data/lib/gem_kit/release/cli/changelog.rb +161 -0
- data/lib/gem_kit/release/cli/command.rb +68 -0
- data/lib/gem_kit/release/cli/deprecations.rb +102 -0
- data/lib/gem_kit/release/cli/release.rb +98 -0
- data/lib/gem_kit/release/cli/setup.rb +136 -0
- data/lib/gem_kit/release/cli/tag.rb +97 -0
- data/lib/gem_kit/release/cli.rb +121 -0
- data/lib/gem_kit/release/deprecate.rb +285 -0
- data/lib/gem_kit/release/gate.rb +178 -0
- data/lib/gem_kit/release/project.rb +188 -0
- data/lib/gem_kit/release/templates/DEPRECATIONS.md.erb +173 -0
- data/lib/gem_kit/release/templates/RELEASE.md.erb +159 -0
- data/lib/gem_kit/release/version.rb +7 -0
- data/lib/gem_kit/release/version_file.rb +133 -0
- data/lib/gem_kit/release.rb +77 -0
- data/lib/rubygems/commands/kit_command.rb +111 -0
- data/lib/rubygems_plugin.rb +11 -0
- metadata +77 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Deprecations
|
|
2
|
+
|
|
3
|
+
A deprecation in <%= name %> is a **dated promise**: it names the replacement
|
|
4
|
+
*and* the version the old name stops existing in. That promise is
|
|
5
|
+
machine-readable — every declaration registers itself, and the release tooling
|
|
6
|
+
refuses to ship a version that breaks one.
|
|
7
|
+
|
|
8
|
+
The mechanism is [`GemKit::Release::Deprecate`](https://rubygems.org/gems/gem_kit-release),
|
|
9
|
+
built on [`Gem::Deprecate`](https://docs.ruby-lang.org/en/master/Gem/Deprecate.html).
|
|
10
|
+
|
|
11
|
+
## The rules
|
|
12
|
+
|
|
13
|
+
1. **Never delete a public name outright.** Leave it working, deprecated, until
|
|
14
|
+
its deadline.
|
|
15
|
+
2. **Every deprecation names a removal version.** The current version is
|
|
16
|
+
<%= version %>, so the usual deadline is <%= next_major %>.
|
|
17
|
+
3. **Removals happen in major versions only.** A minor or patch release never
|
|
18
|
+
takes a name away.
|
|
19
|
+
4. **The deadline is enforced, not remembered.** `gem kit bump` and `gem kit release`
|
|
20
|
+
both refuse to move to a version that has a deprecation coming due.
|
|
21
|
+
5. **Deprecating something is a changelog entry** — under `### Deprecated`,
|
|
22
|
+
naming the replacement and the removal version.
|
|
23
|
+
|
|
24
|
+
## Deprecating a method
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class Session
|
|
28
|
+
extend GemKit::Release::Deprecate
|
|
29
|
+
|
|
30
|
+
def new_reset
|
|
31
|
+
# ...
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def old_reset = new_reset
|
|
35
|
+
|
|
36
|
+
deprecate :old_reset, "Session#new_reset", "<%= next_major %>"
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The old method keeps working and warns on every call, naming the caller:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
NOTE: Session#old_reset is deprecated; use Session#new_reset instead.
|
|
44
|
+
It will be removed in <%= next_major %>
|
|
45
|
+
Session#old_reset called from app.rb:12.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `:none` as the replacement when there genuinely isn't one:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
deprecate :old_reset, :none, "<%= next_major %>"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For a class method, follow the `Gem::Deprecate` idiom — the registry records it
|
|
55
|
+
against the class, not its singleton:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class << self
|
|
59
|
+
extend GemKit::Release::Deprecate
|
|
60
|
+
deprecate :some_class_method, "Other.method", "<%= next_major %>"
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Deprecating a renamed or moved constant
|
|
65
|
+
|
|
66
|
+
Keep the old constant as a subclass of the new one and declare the rename in
|
|
67
|
+
its body:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
module Old
|
|
71
|
+
class Thing < New::Thing
|
|
72
|
+
extend GemKit::Release::Deprecate
|
|
73
|
+
superseded_by "New::Thing", "<%= next_major %>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Old code keeps running unchanged; instantiating the old name warns and points at
|
|
79
|
+
the new one. The whole shim is those four lines — the implementation lives in
|
|
80
|
+
one place.
|
|
81
|
+
|
|
82
|
+
It is `superseded_by` rather than `deprecate_constant` because `Module` already
|
|
83
|
+
has a method by that name, and shadowing it would break callers who use it.
|
|
84
|
+
|
|
85
|
+
## Finding what is outstanding
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
gem kit deprecations
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
1 outstanding deprecation(s) (current version <%= version %>):
|
|
93
|
+
<%= next_major %> Session#old_reset -> Session#new_reset
|
|
94
|
+
lib/session.rb:19
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Pass a version to ask "what comes due here?" — it exits non-zero if anything
|
|
98
|
+
does, which is what makes it usable as a gate in CI:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
gem kit deprecations <%= next_major %>.0.0
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Programmatically, the same data:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
GemKit::Release::Deprecate.registry # every declaration
|
|
108
|
+
GemKit::Release::Deprecate.pending("<%= next_major %>.0.0") # deadlines that have arrived
|
|
109
|
+
GemKit::Release::Deprecate.upcoming("<%= next_major %>.0.0") # still in their grace period
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each entry carries `name`, `replacement`, `removed_in` and `declared_at`.
|
|
113
|
+
|
|
114
|
+
## Paying the debt
|
|
115
|
+
|
|
116
|
+
When a major version comes around, the bump is blocked until the deprecated
|
|
117
|
+
code is actually gone:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
$ gem kit bump major
|
|
121
|
+
ERROR: Refusing to bump <%= version %> -> <%= next_major %>.0.0:
|
|
122
|
+
|
|
123
|
+
<%= next_major %> Session#old_reset -> Session#new_reset
|
|
124
|
+
lib/session.rb:19
|
|
125
|
+
|
|
126
|
+
Remove them, then bump. Override with --force.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
So the order of work is:
|
|
130
|
+
|
|
131
|
+
1. `gem kit deprecations <%= next_major %>.0.0` — read the list.
|
|
132
|
+
2. Delete each deprecated name and its specs. For a constant shim, that means
|
|
133
|
+
deleting the whole file.
|
|
134
|
+
3. Update anything in `examples/` and the docs still using the old name.
|
|
135
|
+
4. Record the removals in `<%= changelog %>` under `### Removed`.
|
|
136
|
+
5. `gem kit bump major` — now it goes through.
|
|
137
|
+
|
|
138
|
+
`--force` exists for the case where you have decided to extend a grace period,
|
|
139
|
+
and it prints what it is overriding. It is not the normal path: extending a
|
|
140
|
+
deadline properly means editing the declaration's version, which keeps the
|
|
141
|
+
registry honest.
|
|
142
|
+
|
|
143
|
+
## Testing deprecated code
|
|
144
|
+
|
|
145
|
+
`Gem::Deprecate.skip_during` silences these warnings too, so a spec can
|
|
146
|
+
exercise the old path without noise:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
Gem::Deprecate.skip_during do
|
|
150
|
+
legacy.old_reset
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
To assert *that* something warns, stub the single funnel every warning goes
|
|
155
|
+
through:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
captured = []
|
|
159
|
+
original = GemKit::Release::Deprecate.method(:warn)
|
|
160
|
+
GemKit::Release::Deprecate.define_singleton_method(:warn) { |message| captured << message }
|
|
161
|
+
begin
|
|
162
|
+
legacy.old_reset
|
|
163
|
+
ensure
|
|
164
|
+
GemKit::Release::Deprecate.define_singleton_method(:warn, original)
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## See also
|
|
169
|
+
|
|
170
|
+
- [RELEASE.md](RELEASE.md) — where the deprecation gates sit in the release
|
|
171
|
+
process.
|
|
172
|
+
- [<%= changelog %>](<%= changelog %>) — the `### Deprecated` and `### Removed`
|
|
173
|
+
sections are the user-facing half of all this.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Releasing <%= name %>
|
|
2
|
+
|
|
3
|
+
The whole process, in order:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
<%= test.ljust(30) %># 1. green suite
|
|
7
|
+
gem kit bump minor # 2. bump (prints: now run gem kit changelog --write)
|
|
8
|
+
gem kit changelog --write # 3. write the entry
|
|
9
|
+
gem kit changelog <VERSION> # 4. check it
|
|
10
|
+
git commit -am "Release ..." # 5. commit the bump + changelog
|
|
11
|
+
gem kit release # 6. build and push
|
|
12
|
+
gem kit tag --push # 7. tag it
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Steps 2 and 6 are gates: they refuse to proceed when something is missing.
|
|
16
|
+
Everything below is what they check and why.
|
|
17
|
+
|
|
18
|
+
## Versioning
|
|
19
|
+
|
|
20
|
+
<%= name %> is [semver](https://semver.org/). The version lives in one place —
|
|
21
|
+
`<%= version_file %>` — and is only ever changed by `gem kit bump`.
|
|
22
|
+
|
|
23
|
+
| Segment | When | What it may contain |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| **major** | A public name disappears or changes meaning | Removals of deprecated names, breaking signature changes |
|
|
26
|
+
| **minor** | New public surface, backwards compatible | New classes and methods; new deprecations |
|
|
27
|
+
| **patch** | Nothing new, nothing gone | Bug fixes, docs, internals |
|
|
28
|
+
|
|
29
|
+
Two rules follow from this, and both are enforced in code:
|
|
30
|
+
|
|
31
|
+
- **Removals only ever land in a major version.** A name promised to disappear
|
|
32
|
+
in <%= next_major %> disappears in <%= next_major %>.0.0, not in a patch.
|
|
33
|
+
- **Deprecating is a minor.** Adding a deprecation puts no obligation on the
|
|
34
|
+
user *yet*, so it does not need a major — but it starts the clock. See
|
|
35
|
+
[DEPRECATIONS.md](DEPRECATIONS.md).
|
|
36
|
+
|
|
37
|
+
## 1. Green suite
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
<%= test %>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A red suite is not a release candidate; nothing downstream checks this for you.
|
|
44
|
+
|
|
45
|
+
## 2. Bump
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
gem kit bump <major|minor|patch>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Rewrites `<%= version_file %>` — by rendering its `.erb` template if there is
|
|
52
|
+
one, otherwise by substituting the version literal in place — and prints the
|
|
53
|
+
transition:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
<%= version %> -> ...
|
|
57
|
+
|
|
58
|
+
now run: gem kit changelog --write
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**It refuses to bump onto a deprecation deadline.** If any registered
|
|
62
|
+
deprecation is due at the new version, it lists them with their source lines
|
|
63
|
+
and fails. Remove the deprecated code first — that is the point of the promise.
|
|
64
|
+
`--force` overrides and says what it overrode. Deprecations *not* yet due are
|
|
65
|
+
reported for information, not blocked on.
|
|
66
|
+
|
|
67
|
+
## 3. Changelog
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
gem kit changelog --write
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Hands the entry to the AI CLI named by `config.changelog_writer` (default:
|
|
74
|
+
`claude`), with a prompt describing the format and this project's conventions.
|
|
75
|
+
It reads the commits since the last tag and edits `<%= changelog %>` only.
|
|
76
|
+
|
|
77
|
+
Review what it writes. It is a first draft with the commits in front of it, not
|
|
78
|
+
an oracle — it cannot know which of two changes mattered to users.
|
|
79
|
+
|
|
80
|
+
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
|
|
81
|
+
|
|
82
|
+
```md
|
|
83
|
+
## [Unreleased]
|
|
84
|
+
|
|
85
|
+
## [<%= version %>] - <%= today %>
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- Something users can now do.
|
|
90
|
+
|
|
91
|
+
### Deprecated
|
|
92
|
+
|
|
93
|
+
- `Old::Name` — use `New::Name` instead. Removed in <%= next_major %>.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Entries go under `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` or
|
|
97
|
+
`Security` — those six and no others. `[Unreleased]` stays at the top, emptied
|
|
98
|
+
of anything that shipped.
|
|
99
|
+
|
|
100
|
+
## 4. Lint the changelog
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
gem kit changelog # format only
|
|
104
|
+
gem kit changelog <%= version %> # format, plus "is this version ready?"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Checks the title, that every heading is `## [Unreleased]` or
|
|
108
|
+
`## [1.2.3] - YYYY-MM-DD`, that versions are valid, dated, unique and ordered
|
|
109
|
+
newest-first, that `###` sections are one of the six types and none are empty —
|
|
110
|
+
and, given a version, that it has a non-empty section at the top of the released
|
|
111
|
+
list. Every problem is reported as `<%= changelog %>:<line> <what>`.
|
|
112
|
+
|
|
113
|
+
## 5. Commit
|
|
114
|
+
|
|
115
|
+
The version bump, the lockfile and the changelog belong in one commit, before
|
|
116
|
+
anything is pushed to RubyGems. A published gem whose changelog is still
|
|
117
|
+
unwritten in git is the failure this process exists to prevent.
|
|
118
|
+
|
|
119
|
+
## 6. Release
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
gem kit release # or: gem kit release --dry-run, which is the CI check
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Two gates, both before `gem build` runs:
|
|
126
|
+
|
|
127
|
+
1. **Changelog** — this version needs its own non-empty, correctly formatted
|
|
128
|
+
section, sitting at the top of the released list.
|
|
129
|
+
2. **Deprecations** — nothing promised to disappear in this version may still
|
|
130
|
+
be in the tree.
|
|
131
|
+
|
|
132
|
+
Then `gem build` and `gem push`. Requires RubyGems push credentials.
|
|
133
|
+
|
|
134
|
+
## 7. Tag
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
gem kit tag --push
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Creates `v<%= version %>`, refusing if it already exists. The tag is also what
|
|
141
|
+
the next `gem kit changelog --write` uses to find the commit range, so a missing
|
|
142
|
+
one makes the following release's changelog harder to write.
|
|
143
|
+
|
|
144
|
+
## When something goes wrong
|
|
145
|
+
|
|
146
|
+
**Published a broken gem.** Don't delete it — `gem yank <%= name %> -v X.Y.Z` if
|
|
147
|
+
it is genuinely dangerous, otherwise ship a patch. Yanking a version other
|
|
148
|
+
people have already locked to breaks their builds.
|
|
149
|
+
|
|
150
|
+
**Bumped but the release failed.** The bump is just a file. Fix the cause and
|
|
151
|
+
re-run `gem kit release`; there is no need to un-bump.
|
|
152
|
+
|
|
153
|
+
**Changelog written for the wrong version.** Edit the heading and re-run
|
|
154
|
+
`gem kit changelog <version>`. Nothing downstream caches it.
|
|
155
|
+
|
|
156
|
+
## See also
|
|
157
|
+
|
|
158
|
+
- [DEPRECATIONS.md](DEPRECATIONS.md) — the deprecation policy the gates enforce.
|
|
159
|
+
- [<%= changelog %>](<%= changelog %>).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
# Owns the one file that states the version, and the semver arithmetic for
|
|
8
|
+
# moving it. Two layouts are supported, and which one is in play is decided
|
|
9
|
+
# by whether an ERB template sits beside the file:
|
|
10
|
+
#
|
|
11
|
+
# lib/x/version.rb.erb present -> render it with `version` in scope
|
|
12
|
+
# otherwise -> substitute the version string in place
|
|
13
|
+
#
|
|
14
|
+
# The second is the common case and deliberately surgical: it rewrites the
|
|
15
|
+
# quoted version literal and touches nothing else in the file.
|
|
16
|
+
class VersionFile
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
SEGMENTS = %w[major minor patch].freeze
|
|
20
|
+
|
|
21
|
+
# A quoted semver literal, e.g. VERSION = "4.1.0"
|
|
22
|
+
LITERAL = /(["'])(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?)\1/
|
|
23
|
+
|
|
24
|
+
attr_reader :path, :template
|
|
25
|
+
|
|
26
|
+
def initialize(path, template: nil)
|
|
27
|
+
@path = path
|
|
28
|
+
@template = template
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "4.1.0", :minor -> "4.2.0"
|
|
32
|
+
def self.bump(version, segment)
|
|
33
|
+
unless SEGMENTS.include?(segment.to_s)
|
|
34
|
+
raise Error, "unknown segment #{segment.inspect} (expected #{SEGMENTS.join(", ")})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
major, minor, patch = version.to_s.split(".").map(&:to_i)
|
|
38
|
+
case segment.to_s
|
|
39
|
+
when "major" then "#{major + 1}.0.0"
|
|
40
|
+
when "minor" then "#{major}.#{minor + 1}.0"
|
|
41
|
+
when "patch" then "#{major}.#{minor}.#{patch + 1}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The version currently written in the file.
|
|
46
|
+
def read
|
|
47
|
+
contents = File.read(path)
|
|
48
|
+
match = contents.match(LITERAL)
|
|
49
|
+
raise Error, "no version literal in #{path}" if match.nil?
|
|
50
|
+
|
|
51
|
+
match[2]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Write `version` into the file. Returns the version written.
|
|
55
|
+
def write(version)
|
|
56
|
+
if template
|
|
57
|
+
File.write(path, ERB.new(File.read(template)).result(binding))
|
|
58
|
+
else
|
|
59
|
+
contents = File.read(path)
|
|
60
|
+
raise Error, "no version literal in #{path}" unless contents.match?(LITERAL)
|
|
61
|
+
|
|
62
|
+
File.write(path, contents.sub(LITERAL) { "#{$1}#{version}#{$1}" })
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
version
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
__END__
|
|
72
|
+
|
|
73
|
+
describe "gem_kit/release/version_file" do
|
|
74
|
+
require "tmpdir"
|
|
75
|
+
|
|
76
|
+
VF = GemKit::Release::VersionFile unless defined?(VF)
|
|
77
|
+
|
|
78
|
+
with_file = lambda do |contents, &block|
|
|
79
|
+
Dir.mktmpdir do |dir|
|
|
80
|
+
path = File.join(dir, "version.rb")
|
|
81
|
+
File.write(path, contents)
|
|
82
|
+
block.call(path, dir)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "bumps each segment, zeroing the ones below it" do
|
|
87
|
+
VF.bump("4.1.3", :major).should == "5.0.0"
|
|
88
|
+
VF.bump("4.1.3", :minor).should == "4.2.0"
|
|
89
|
+
VF.bump("4.1.3", :patch).should == "4.1.4"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "rejects an unknown segment" do
|
|
93
|
+
lambda { VF.bump("1.0.0", :epoch) }.should.raise(GemKit::Release::VersionFile::Error)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "reads the version literal out of the file" do
|
|
97
|
+
with_file.call(%(module Demo\n VERSION = "1.2.3"\nend\n)) do |path|
|
|
98
|
+
VF.new(path).read.should == "1.2.3"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "rewrites only the version literal, leaving the rest of the file alone" do
|
|
103
|
+
original = %(# frozen_string_literal: true\n\nmodule Demo\n VERSION = "1.2.3" # keep\nend\n)
|
|
104
|
+
with_file.call(original) do |path|
|
|
105
|
+
VF.new(path).write("2.0.0").should == "2.0.0"
|
|
106
|
+
File.read(path).should == original.sub("1.2.3", "2.0.0")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "handles single-quoted literals" do
|
|
111
|
+
with_file.call(%(VERSION = '0.9.0'\n)) do |path|
|
|
112
|
+
VF.new(path).write("0.10.0")
|
|
113
|
+
File.read(path).should == %(VERSION = '0.10.0'\n)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "raises when there is no version literal to rewrite" do
|
|
118
|
+
with_file.call("module Demo\nend\n") do |path|
|
|
119
|
+
lambda { VF.new(path).read }.should.raise(GemKit::Release::VersionFile::Error)
|
|
120
|
+
lambda { VF.new(path).write("1.0.0") }.should.raise(GemKit::Release::VersionFile::Error)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "renders an ERB template when the project generates its version file" do
|
|
125
|
+
with_file.call(%(VERSION = "0.0.0"\n)) do |path, dir|
|
|
126
|
+
template = File.join(dir, "version.rb.erb")
|
|
127
|
+
File.write(template, %(module Demo\n VERSION = "<%= version %>"\nend\n))
|
|
128
|
+
|
|
129
|
+
VF.new(path, template: template).write("3.1.4")
|
|
130
|
+
File.read(path).should == %(module Demo\n VERSION = "3.1.4"\nend\n)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "release/version"
|
|
4
|
+
require_relative "release/deprecate"
|
|
5
|
+
require_relative "release/changelog"
|
|
6
|
+
require_relative "release/project"
|
|
7
|
+
require_relative "release/version_file"
|
|
8
|
+
require_relative "release/gate"
|
|
9
|
+
|
|
10
|
+
module GemKit
|
|
11
|
+
# Versioning, changelog and deprecation gates for Ruby gems.
|
|
12
|
+
#
|
|
13
|
+
# The premise: a deprecation is a dated promise — it names its replacement
|
|
14
|
+
# and the version the old name stops existing in — and a release is only
|
|
15
|
+
# honest if it keeps every promise that has come due and documents what
|
|
16
|
+
# changed. Both are checkable, so neither should depend on anyone
|
|
17
|
+
# remembering.
|
|
18
|
+
#
|
|
19
|
+
# In a Rakefile, which is the whole integration:
|
|
20
|
+
#
|
|
21
|
+
# require "gem_kit/release/tasks"
|
|
22
|
+
#
|
|
23
|
+
# Everything else is inferred from the project's .gemspec. Override only
|
|
24
|
+
# what cannot be:
|
|
25
|
+
#
|
|
26
|
+
# GemKit::Release.configure do |config|
|
|
27
|
+
# config.changelog = "HISTORY.md"
|
|
28
|
+
# config.test_command = "bin/test"
|
|
29
|
+
# config.changelog_writer = "claude"
|
|
30
|
+
# end
|
|
31
|
+
module Release
|
|
32
|
+
def self.config
|
|
33
|
+
@config ||= Project::Config.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.configure
|
|
37
|
+
yield config
|
|
38
|
+
config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The project the tooling is running in.
|
|
42
|
+
def self.project(dir = Dir.pwd)
|
|
43
|
+
Project.detect(dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.gate(dir = Dir.pwd)
|
|
47
|
+
Gate.new(project(dir))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reset configuration — for tests.
|
|
51
|
+
def self.reset!
|
|
52
|
+
@config = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
__END__
|
|
58
|
+
|
|
59
|
+
describe "gem_kit/release" do
|
|
60
|
+
it "exposes a configurable, resettable config" do
|
|
61
|
+
begin
|
|
62
|
+
GemKit::Release.configure { |c| c.changelog = "HISTORY.md" }
|
|
63
|
+
GemKit::Release.config.changelog.should == "HISTORY.md"
|
|
64
|
+
ensure
|
|
65
|
+
GemKit::Release.reset!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
GemKit::Release.config.changelog.should.be.nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "detects itself as a project" do
|
|
72
|
+
project = GemKit::Release.project(File.expand_path("../..", __dir__))
|
|
73
|
+
project.name.should == "gem_kit-release"
|
|
74
|
+
project.version.should == Gem::Version.new(GemKit::Release::VERSION)
|
|
75
|
+
project.require_path.should == "gem_kit/release"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/command"
|
|
4
|
+
|
|
5
|
+
module Gem
|
|
6
|
+
module Commands
|
|
7
|
+
# `gem kit <subcommand>` — the only command this gem registers.
|
|
8
|
+
#
|
|
9
|
+
# One registration rather than six keeps the toolchain under a name that is
|
|
10
|
+
# obviously ours, leaves `gem help commands` readable, and means nothing
|
|
11
|
+
# here can collide with a command RubyGems adds later.
|
|
12
|
+
#
|
|
13
|
+
# This class is a bridge and nothing else: it hands the argv to the dry-cli
|
|
14
|
+
# registry, which owns the parsing and the help pages, and turns the result
|
|
15
|
+
# into a RubyGems exit. handle_options is deliberately inert — parsing here
|
|
16
|
+
# would swallow flags meant for a subcommand.
|
|
17
|
+
class KitCommand < Gem::Command
|
|
18
|
+
def initialize
|
|
19
|
+
super("kit", "Versioning, changelog and deprecation gates for this gem")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def arguments
|
|
23
|
+
<<~TXT
|
|
24
|
+
setup copy DEPRECATIONS.md and RELEASE.md into this project
|
|
25
|
+
bump move the version, refusing to bump onto a deprecation deadline
|
|
26
|
+
changelog lint CHANGELOG.md, or have an AI CLI write this version's entry
|
|
27
|
+
deprecations list the deprecations this gem has not yet honoured
|
|
28
|
+
release gate, build and push this gem
|
|
29
|
+
tag tag the current version in git
|
|
30
|
+
TXT
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def usage = "#{program_name} SUBCOMMAND [options]"
|
|
34
|
+
|
|
35
|
+
def defaults_str = "(lists the subcommands)"
|
|
36
|
+
|
|
37
|
+
def description
|
|
38
|
+
<<~TXT
|
|
39
|
+
A deprecation is a dated promise: it names its replacement and the version
|
|
40
|
+
the old name stops existing in. These commands keep those promises — `bump`
|
|
41
|
+
refuses to move onto a deadline, and `release` refuses to ship a version with
|
|
42
|
+
an unkept promise or no changelog entry of its own.
|
|
43
|
+
|
|
44
|
+
Everything is read from the .gemspec in the working directory, so there is
|
|
45
|
+
nothing to configure in the common case.
|
|
46
|
+
|
|
47
|
+
Run `gem kit SUBCOMMAND --help` for one subcommand's arguments, options and
|
|
48
|
+
examples.
|
|
49
|
+
TXT
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inert on purpose: the dry-cli registry parses the argv, including the
|
|
53
|
+
# subcommand's own flags.
|
|
54
|
+
def handle_options(args)
|
|
55
|
+
@argv = args.dup
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute
|
|
59
|
+
# require_relative, not require: this resolves whether or not the gem
|
|
60
|
+
# is installed, which keeps the command testable from the repo.
|
|
61
|
+
require_relative "../../gem_kit/release/cli"
|
|
62
|
+
|
|
63
|
+
terminate_interaction(GemKit::Release::CLI.run(@argv || []))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
__END__
|
|
70
|
+
|
|
71
|
+
describe "rubygems/commands/kit_command" do
|
|
72
|
+
require_relative "../../../spec/support/gem_kit_release_spec"
|
|
73
|
+
extend GemKitReleaseSpec
|
|
74
|
+
|
|
75
|
+
it "dispatches to a subcommand, passing its arguments through" do
|
|
76
|
+
with_gem do |dir|
|
|
77
|
+
status, out, _err = invoke(["bump", "minor"], dir)
|
|
78
|
+
|
|
79
|
+
status.should == 0
|
|
80
|
+
out.should.match(/1\.2\.3 -> 1\.3\.0/)
|
|
81
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.3\.0"/)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "passes flags through to the subcommand's own parser" do
|
|
86
|
+
with_gem do |dir|
|
|
87
|
+
status, out, _err = invoke(["bump", "major", "--force"], dir,
|
|
88
|
+
deprecations: [["Old", "New", "2.0"]])
|
|
89
|
+
|
|
90
|
+
status.should == 0
|
|
91
|
+
out.should.match(/--force given/)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "propagates a subcommand's failure as a non-zero status" do
|
|
96
|
+
with_gem do |dir|
|
|
97
|
+
status, _out, err = invoke(["changelog", "9.9.9"], dir)
|
|
98
|
+
status.should == 1
|
|
99
|
+
err.should.match(/no section for 9\.9\.9/)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "describes itself for `gem help kit`" do
|
|
104
|
+
command = Gem::Commands::KitCommand.new
|
|
105
|
+
|
|
106
|
+
command.command.should == "kit"
|
|
107
|
+
command.usage.should.match(/gem kit SUBCOMMAND/)
|
|
108
|
+
command.arguments.should.match(/deprecations/)
|
|
109
|
+
command.description.should.match(/dated promise/)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RubyGems loads this file from every installed gem's lib/ on every `gem`
|
|
4
|
+
# invocation, which is how `gem kit` becomes a real command. Keep it to the
|
|
5
|
+
# require and the registration — anything heavier is a tax on `gem list`.
|
|
6
|
+
|
|
7
|
+
require "rubygems/command_manager"
|
|
8
|
+
|
|
9
|
+
require_relative "rubygems/commands/kit_command"
|
|
10
|
+
|
|
11
|
+
Gem::CommandManager.instance.register_command :kit
|