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.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module GemKit
6
+ module Release
7
+ module CLI
8
+ # Without a version: everything outstanding. With one: only what comes
9
+ # due there, failing if anything does — which is what makes it usable as
10
+ # a CI gate.
11
+ class Deprecations < Command
12
+ desc "List the deprecations this gem has not yet honoured"
13
+
14
+ argument :version,
15
+ type: :string, required: false,
16
+ desc: "List only what comes due at this version, and fail if any does"
17
+
18
+ example [
19
+ " # everything outstanding, by removal version",
20
+ "5.0.0 # only what comes due at 5.0.0; fails if any does",
21
+ ]
22
+
23
+ def call(version: nil, **)
24
+ project.load!
25
+ registry = Deprecate.registry
26
+
27
+ if registry.empty?
28
+ say("No deprecations declared.")
29
+ return
30
+ end
31
+
32
+ if version.nil?
33
+ say("#{registry.size} outstanding deprecation(s) (current version #{project.version}):")
34
+ return render(registry)
35
+ end
36
+
37
+ due = Deprecate.pending(version)
38
+ if due.empty?
39
+ say("No deprecations come due at #{version}. (#{registry.size} outstanding overall.)")
40
+ return
41
+ end
42
+
43
+ refuse("#{due.size} deprecation(s) come due at #{version} and must be removed first:", lines(due))
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ __END__
51
+
52
+ describe "gem_kit/release/cli/deprecations" do
53
+ require_relative "../../../../spec/support/gem_kit_release_spec"
54
+ extend GemKitReleaseSpec
55
+
56
+ it "says so when nothing is declared" do
57
+ with_gem do |dir|
58
+ status, out, _err = invoke(["deprecations"], dir)
59
+ status.should == 0
60
+ out.should.match(/No deprecations declared/)
61
+ end
62
+ end
63
+
64
+ it "lists everything outstanding with its deadline" do
65
+ with_gem do |dir|
66
+ status, out, _err = invoke(["deprecations"], dir,
67
+ deprecations: [["Old", "New", "2.0"], ["Older", "Newer", "3.0"]])
68
+
69
+ status.should == 0
70
+ out.should.match(/2 outstanding deprecation\(s\) \(current version 1\.2\.3\)/)
71
+ out.should.match(/2\.0\s+Old -> New/)
72
+ out.should.match(/3\.0\s+Older -> Newer/)
73
+ end
74
+ end
75
+
76
+ it "fails for a version with deprecations due, naming only those" do
77
+ with_gem do |dir|
78
+ status, _out, err = invoke(["deprecations", "2.0.0"], dir,
79
+ deprecations: [["Old", "New", "2.0"], ["Later", "Newer", "3.0"]])
80
+
81
+ status.should == 1
82
+ err.should.match(/1 deprecation\(s\) come due at 2\.0\.0/)
83
+ err.should.match(/Old -> New/)
84
+ err.should.not.match(/Later/)
85
+ end
86
+ end
87
+
88
+ it "passes for a version with nothing due, but says what remains" do
89
+ with_gem do |dir|
90
+ status, out, _err = invoke(["deprecations", "1.5.0"], dir, deprecations: [["Old", "New", "2.0"]])
91
+
92
+ status.should == 0
93
+ out.should.match(/No deprecations come due at 1\.5\.0\. \(1 outstanding overall\.\)/)
94
+ end
95
+ end
96
+
97
+ it "is reachable by its alias" do
98
+ with_gem do |dir|
99
+ invoke(["deps"], dir).first.should == 0
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module GemKit
6
+ module Release
7
+ module CLI
8
+ # Gate, build, push. The gates run before anything is built, because a
9
+ # half-done release is worse than a refused one.
10
+ class Release < Command
11
+ desc "Gate, build and push this gem"
12
+
13
+ option :dry_run, type: :boolean, default: false, desc: "Run the gates and stop"
14
+ option :tag, type: :boolean, default: false, desc: "Tag the release after pushing"
15
+
16
+ example [
17
+ "--dry-run # run the gates; what belongs in CI",
18
+ "--tag # release, then tag it",
19
+ ]
20
+
21
+ def call(**options)
22
+ version = project.version.to_s
23
+ problems = gate.release_problems(version)
24
+
25
+ refuse("Refusing to release #{project.name} #{version}:", problems) unless problems.empty?
26
+
27
+ if options[:dry_run]
28
+ say("#{project.name} #{version} is ready to release.")
29
+ return
30
+ end
31
+
32
+ gemspec = File.basename(project.gemspec_path)
33
+ package = "#{project.name}-#{version}.gem"
34
+
35
+ Dir.chdir(project.root) do
36
+ say("Building #{gemspec}…")
37
+ fail_with("gem build failed") unless system("gem", "build", gemspec)
38
+
39
+ say("Pushing #{package}…")
40
+ fail_with("gem push failed") unless system("gem", "push", package)
41
+ end
42
+
43
+ say("Released #{project.name} #{version}")
44
+
45
+ Tag.new.call if options[:tag]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ __END__
53
+
54
+ describe "gem_kit/release/cli/release" do
55
+ require_relative "../../../../spec/support/gem_kit_release_spec"
56
+ extend GemKitReleaseSpec
57
+
58
+ it "--dry-run passes when the changelog documents the version" do
59
+ with_gem do |dir|
60
+ status, out, _err = invoke(["release", "--dry-run"], dir)
61
+ status.should == 0
62
+ out.should.match(/demo 1\.2\.3 is ready to release/)
63
+ end
64
+ end
65
+
66
+ it "refuses without a changelog entry, before building anything" do
67
+ with_gem(changelog: :none) do |dir|
68
+ status, _out, err = invoke(["release"], dir)
69
+
70
+ status.should == 1
71
+ err.should.match(/Refusing to release demo 1\.2\.3/)
72
+ err.should.match(/does not exist/)
73
+ Dir[File.join(dir, "*.gem")].should.be.empty
74
+ end
75
+ end
76
+
77
+ it "refuses when a deprecation is due in this version, before building anything" do
78
+ with_gem do |dir|
79
+ status, _out, err = invoke(["release"], dir, deprecations: [["Old", "New", "1.0"]])
80
+
81
+ status.should == 1
82
+ err.should.match(/deprecation due in 1\.2\.3: .*Old -> New/)
83
+ Dir[File.join(dir, "*.gem")].should.be.empty
84
+ end
85
+ end
86
+
87
+ it "does not gate on a deprecation that is not yet due" do
88
+ with_gem do |dir|
89
+ invoke(["release", "--dry-run"], dir, deprecations: [["Old", "New", "9.0"]]).first.should == 0
90
+ end
91
+ end
92
+
93
+ it "reports a directory with no gemspec" do
94
+ Dir.mktmpdir do |dir|
95
+ invoke(["release", "--dry-run"], dir).first.should == 1
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ require_relative "command"
6
+
7
+ module GemKit
8
+ module Release
9
+ module CLI
10
+ # Copies the two documents that describe this toolchain into the project,
11
+ # rendered for its name and version.
12
+ #
13
+ # They are copies rather than links because a policy nobody can read in
14
+ # their own repo is a policy nobody follows, and because the examples are
15
+ # worth more when they use the project's real version numbers.
16
+ class Setup < Command
17
+ TEMPLATES = File.expand_path("../templates", __dir__)
18
+
19
+ DOCUMENTS = %w[DEPRECATIONS.md RELEASE.md].freeze
20
+
21
+ desc "Copy DEPRECATIONS.md and RELEASE.md into this project"
22
+
23
+ option :force, type: :boolean, default: false, desc: "Overwrite documents that already exist"
24
+
25
+ example [
26
+ " # write the documents that are missing",
27
+ "--force # refresh them all, e.g. after a major version",
28
+ ]
29
+
30
+ def call(**options)
31
+ written, skipped = [], []
32
+
33
+ DOCUMENTS.each do |document|
34
+ destination = File.join(project.root, document)
35
+
36
+ if File.exist?(destination) && !options[:force]
37
+ skipped << document
38
+ next
39
+ end
40
+
41
+ File.write(destination, render_document(document))
42
+ written << document
43
+ end
44
+
45
+ written.each { |document| say("Wrote #{document}") }
46
+
47
+ unless skipped.empty?
48
+ say("Left alone (already present): #{skipped.join(", ")}")
49
+ say("Pass --force to overwrite.")
50
+ end
51
+
52
+ say("Link them from your README and AGENTS.md so they are found.") unless written.empty?
53
+ end
54
+
55
+ private
56
+
57
+ # Values the templates may use. Kept small and obvious — a template
58
+ # needing more than this is documenting the tool, not the project.
59
+ def render_document(document)
60
+ name = project.name
61
+ version = project.version.to_s
62
+ next_major = project.next_major_version
63
+ changelog = File.basename(project.changelog_path)
64
+ version_file = relative(project.version_file)
65
+ test = project.test_command
66
+ today = Time.now.strftime("%Y-%m-%d")
67
+
68
+ template = File.read(File.join(TEMPLATES, "#{document}.erb"))
69
+ ERB.new(template, trim_mode: "-").result(binding)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ __END__
77
+
78
+ describe "gem_kit/release/cli/setup" do
79
+ require_relative "../../../../spec/support/gem_kit_release_spec"
80
+ extend GemKitReleaseSpec
81
+
82
+ it "writes both documents into the project root" do
83
+ with_gem do |dir|
84
+ status, out, _err = invoke(["setup"], dir)
85
+
86
+ status.should == 0
87
+ out.should.match(/Wrote DEPRECATIONS\.md/)
88
+ out.should.match(/Wrote RELEASE\.md/)
89
+ File.exist?(File.join(dir, "DEPRECATIONS.md")).should.be.true
90
+ File.exist?(File.join(dir, "RELEASE.md")).should.be.true
91
+ end
92
+ end
93
+
94
+ it "renders them for this project's name and versions" do
95
+ with_gem(version: "4.1.0") do |dir|
96
+ invoke(["setup"], dir)
97
+
98
+ deprecations = File.read(File.join(dir, "DEPRECATIONS.md"))
99
+ deprecations.should.match(/A deprecation in demo is a \*\*dated promise\*\*/)
100
+ deprecations.should.match(/4\.1\.0, so the usual deadline is 5/)
101
+ deprecations.should.not.match(/<%=/)
102
+
103
+ release = File.read(File.join(dir, "RELEASE.md"))
104
+ release.should.match(/demo/)
105
+ release.should.not.match(/<%=/)
106
+ end
107
+ end
108
+
109
+ it "leaves existing documents alone and says so" do
110
+ with_gem do |dir|
111
+ File.write(File.join(dir, "RELEASE.md"), "mine\n")
112
+
113
+ status, out, _err = invoke(["setup"], dir)
114
+
115
+ status.should == 0
116
+ out.should.match(/Left alone \(already present\): RELEASE\.md/)
117
+ File.read(File.join(dir, "RELEASE.md")).should == "mine\n"
118
+ end
119
+ end
120
+
121
+ it "--force overwrites them" do
122
+ with_gem do |dir|
123
+ File.write(File.join(dir, "RELEASE.md"), "mine\n")
124
+
125
+ invoke(["setup", "--force"], dir).first.should == 0
126
+ File.read(File.join(dir, "RELEASE.md")).should.not == "mine\n"
127
+ end
128
+ end
129
+
130
+ it "is reachable by its alias" do
131
+ with_gem do |dir|
132
+ invoke(["init"], dir).first.should == 0
133
+ File.exist?(File.join(dir, "RELEASE.md")).should.be.true
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module GemKit
6
+ module Release
7
+ module CLI
8
+ # The tag matters beyond bookkeeping: it is what the next changelog is
9
+ # written against, so a missing one makes the following release harder to
10
+ # describe.
11
+ class Tag < Command
12
+ desc "Tag the current version in git"
13
+
14
+ option :push, type: :boolean, default: false, desc: "Push the tag to origin"
15
+ option :prefix, type: :string, default: "v", desc: "Tag name prefix"
16
+
17
+ example [
18
+ " # tag v4.1.0",
19
+ "--push # ...and push it",
20
+ "--prefix release- # tag release-4.1.0",
21
+ ]
22
+
23
+ def call(**options)
24
+ tag = "#{options.fetch(:prefix, "v")}#{project.version}"
25
+
26
+ Dir.chdir(project.root) do
27
+ fail_with("not a git repository") unless system("git rev-parse --git-dir >/dev/null 2>&1")
28
+ fail_with("tag #{tag} already exists") unless `git tag -l #{tag}`.strip.empty?
29
+ fail_with("could not create tag #{tag}") unless system("git", "tag", "-a", tag, "-m", tag)
30
+
31
+ say("Tagged #{tag}")
32
+
33
+ if options[:push]
34
+ fail_with("could not push #{tag}") unless system("git", "push", "origin", tag)
35
+ say("Pushed #{tag}")
36
+ else
37
+ say("Push it with: git push origin #{tag}")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ __END__
47
+
48
+ describe "gem_kit/release/cli/tag" do
49
+ require_relative "../../../../spec/support/gem_kit_release_spec"
50
+ extend GemKitReleaseSpec
51
+
52
+ # git needs an identity and at least one commit before it will tag.
53
+ as_repo = lambda do |dir|
54
+ system("git init -q #{dir}")
55
+ system("git -C #{dir} -c user.name=t -c user.email=t@t commit -q --allow-empty -m init")
56
+ end
57
+
58
+ it "tags the current version and says how to push it" do
59
+ with_gem do |dir|
60
+ as_repo.call(dir)
61
+
62
+ status, out, _err = invoke(["tag"], dir)
63
+ status.should == 0
64
+ out.should.match(/Tagged v1\.2\.3/)
65
+ out.should.match(/git push origin v1\.2\.3/)
66
+ `git -C #{dir} tag -l`.strip.should == "v1.2.3"
67
+ end
68
+ end
69
+
70
+ it "honours a custom prefix" do
71
+ with_gem do |dir|
72
+ as_repo.call(dir)
73
+
74
+ invoke(["tag", "--prefix", "release-"], dir).first.should == 0
75
+ `git -C #{dir} tag -l`.strip.should == "release-1.2.3"
76
+ end
77
+ end
78
+
79
+ it "refuses to retag an existing version" do
80
+ with_gem do |dir|
81
+ as_repo.call(dir)
82
+ invoke(["tag"], dir)
83
+
84
+ status, _out, err = invoke(["tag"], dir)
85
+ status.should == 1
86
+ err.should.match(/tag v1\.2\.3 already exists/)
87
+ end
88
+ end
89
+
90
+ it "reports a directory that is not a git repository" do
91
+ with_gem do |dir|
92
+ status, _out, err = invoke(["tag"], dir)
93
+ status.should == 1
94
+ err.should.match(/not a git repository/)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ require_relative "cli/command"
6
+ require_relative "cli/bump"
7
+ require_relative "cli/changelog"
8
+ require_relative "cli/deprecations"
9
+ require_relative "cli/release"
10
+ require_relative "cli/setup"
11
+ require_relative "cli/tag"
12
+
13
+ module GemKit
14
+ module Release
15
+ # The command registry. dry-cli gives us the parts a hand-rolled parser
16
+ # always ends up needing badly: per-command help pages built from the
17
+ # `argument`/`option`/`example` declarations, value validation, and a
18
+ # listing of the subcommands.
19
+ #
20
+ # gem kit the listing
21
+ # gem kit bump --help that command's arguments, options, examples
22
+ #
23
+ module CLI
24
+ extend Dry::CLI::Registry
25
+
26
+ register "setup", Setup, aliases: ["init"]
27
+ register "bump", Bump
28
+ register "changelog", Changelog, aliases: ["log"]
29
+ register "deprecations", Deprecations, aliases: ["deps"]
30
+ register "release", Release
31
+ register "tag", Tag
32
+
33
+ # How the commands are reached, which is not something dry-cli can work
34
+ # out: it takes the program name from $PROGRAM_NAME, and ours is `gem`.
35
+ PROGRAM_NAME = "gem kit"
36
+
37
+ # Run one invocation. Returns an exit status rather than exiting, so the
38
+ # `gem kit` bridge can hand it to terminate_interaction and the specs can
39
+ # assert on it.
40
+ def self.run(arguments, out: $stdout, err: $stderr)
41
+ with_program_name do
42
+ Dry::CLI.new(self).call(arguments: arguments, out: out, err: err)
43
+ end
44
+ 0
45
+ rescue Failure => failure
46
+ err.puts(failure.message)
47
+ 1
48
+ rescue SystemExit => exit_exception
49
+ # dry-cli exits on an unknown command or a bad argument value.
50
+ exit_exception.status
51
+ end
52
+
53
+ # dry-cli renders usage as `File.basename($PROGRAM_NAME) <command>`, so
54
+ # every help page would otherwise say `gem bump` — or, under the test
55
+ # runner, `-e`. Basename of "gem kit" is "gem kit", which is exactly the
56
+ # string we want.
57
+ def self.with_program_name
58
+ original = $PROGRAM_NAME
59
+ $PROGRAM_NAME = PROGRAM_NAME
60
+ yield
61
+ ensure
62
+ $PROGRAM_NAME = original
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ __END__
69
+
70
+ describe "gem_kit/release/cli" do
71
+ require_relative "../../../spec/support/gem_kit_release_spec"
72
+ extend GemKitReleaseSpec
73
+
74
+ # With no subcommand dry-cli treats it as a usage error: the listing goes to
75
+ # stderr and the status is non-zero, the way `git` alone behaves.
76
+ it "lists every subcommand with its description" do
77
+ with_gem do |dir|
78
+ status, _out, err = invoke([], dir)
79
+
80
+ status.should.not == 0
81
+ %w[setup bump changelog deprecations release tag].each do |name|
82
+ err.should.match(/#{name}/)
83
+ end
84
+ err.should.match(/Move the gem version/)
85
+ end
86
+ end
87
+
88
+ it "names the commands `gem kit <subcommand>`, not the running program" do
89
+ with_gem do |dir|
90
+ _status, _out, err = invoke([], dir)
91
+
92
+ err.should.match(/gem kit bump \[SEGMENT\]/)
93
+ err.should.not.match(/^\s+-e /)
94
+ end
95
+ end
96
+
97
+ it "restores $PROGRAM_NAME afterwards" do
98
+ with_gem do |dir|
99
+ before = $PROGRAM_NAME
100
+ invoke([], dir)
101
+ $PROGRAM_NAME.should == before
102
+ end
103
+ end
104
+
105
+ it "prints a subcommand's arguments, options and examples for --help" do
106
+ with_gem do |dir|
107
+ _status, out, _err = invoke(["bump", "--help"], dir)
108
+
109
+ out.should.match(/gem kit bump/)
110
+ out.should.match(/SEGMENT/)
111
+ out.should.match(/--force/)
112
+ out.should.match(/Examples:/)
113
+ end
114
+ end
115
+
116
+ it "fails on an unknown subcommand" do
117
+ with_gem do |dir|
118
+ invoke(["nonsense"], dir).first.should.not == 0
119
+ end
120
+ end
121
+ end