schmersion 1.0.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/VERSION +1 -0
- data/bin/schmersion +20 -0
- data/cli/help.rb +21 -0
- data/cli/init.rb +49 -0
- data/cli/lint-prepare.rb +14 -0
- data/cli/lint-validate.rb +28 -0
- data/cli/log.rb +28 -0
- data/cli/pending.rb +49 -0
- data/cli/release.rb +40 -0
- data/cli/setup-linting.rb +22 -0
- data/cli/versions.rb +14 -0
- data/lib/schmersion.rb +4 -0
- data/lib/schmersion/commit.rb +23 -0
- data/lib/schmersion/commit_parser.rb +60 -0
- data/lib/schmersion/config.rb +67 -0
- data/lib/schmersion/error.rb +6 -0
- data/lib/schmersion/formatter.rb +30 -0
- data/lib/schmersion/formatters.rb +15 -0
- data/lib/schmersion/formatters/markdown.rb +108 -0
- data/lib/schmersion/formatters/yaml.rb +63 -0
- data/lib/schmersion/helpers.rb +32 -0
- data/lib/schmersion/host.rb +25 -0
- data/lib/schmersion/hosts.rb +25 -0
- data/lib/schmersion/hosts/github.rb +50 -0
- data/lib/schmersion/linter.rb +147 -0
- data/lib/schmersion/message.rb +95 -0
- data/lib/schmersion/message_validator.rb +67 -0
- data/lib/schmersion/releaser.rb +135 -0
- data/lib/schmersion/repo.rb +127 -0
- data/lib/schmersion/schmersion_version.rb +12 -0
- data/lib/schmersion/version.rb +32 -0
- data/lib/schmersion/version_calculator.rb +71 -0
- metadata +207 -0
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Message
|
5
|
+
|
6
|
+
REGEX = /^([\w-]+)(?:\(([\w-]+)\))?(!)?:\s(.*?)(?:\(#(\d+)\))?$/.freeze
|
7
|
+
FOOTER_COLON_REGEX = /^([\w-]+:|BREAKING CHANGE:)\s*(.*)$/.freeze
|
8
|
+
FOOTER_TICKET_REGEX = /^([\w-]+)\s+(#.*)$/.freeze
|
9
|
+
|
10
|
+
attr_reader :header
|
11
|
+
attr_reader :footers
|
12
|
+
attr_reader :type
|
13
|
+
attr_reader :scope
|
14
|
+
attr_reader :description
|
15
|
+
attr_reader :body
|
16
|
+
attr_reader :pull_request_id
|
17
|
+
attr_reader :breaking_changes
|
18
|
+
|
19
|
+
def initialize(raw_message)
|
20
|
+
@raw_message = clean(raw_message)
|
21
|
+
|
22
|
+
parts = @raw_message.split("\n\n").map(&:strip)
|
23
|
+
|
24
|
+
@header = parts.shift
|
25
|
+
@paragraphs = parts
|
26
|
+
@breaking_changes = []
|
27
|
+
@footers = []
|
28
|
+
|
29
|
+
parse_header
|
30
|
+
parse_footers
|
31
|
+
|
32
|
+
@body = @paragraphs.join("\n\n")
|
33
|
+
end
|
34
|
+
|
35
|
+
def valid?
|
36
|
+
!@type.nil?
|
37
|
+
end
|
38
|
+
|
39
|
+
def breaking_change?
|
40
|
+
@breaking_change == true ||
|
41
|
+
@breaking_changes.size.positive?
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def parse_header
|
47
|
+
match = @header.match(REGEX)
|
48
|
+
return unless match
|
49
|
+
|
50
|
+
@type = match[1]
|
51
|
+
@scope = match[2]
|
52
|
+
@description = match[4].strip
|
53
|
+
@pull_request_id = match[5]
|
54
|
+
|
55
|
+
@breaking_change = true if match[3] == '!'
|
56
|
+
|
57
|
+
match
|
58
|
+
end
|
59
|
+
|
60
|
+
def parse_footers
|
61
|
+
footer = []
|
62
|
+
@paragraphs.last&.each_line do |line|
|
63
|
+
case line.strip
|
64
|
+
when FOOTER_COLON_REGEX, FOOTER_TICKET_REGEX
|
65
|
+
if footer.any?
|
66
|
+
handle_footer(footer)
|
67
|
+
footer = []
|
68
|
+
end
|
69
|
+
footer << "#{Regexp.last_match(1)} #{Regexp.last_match(2)}"
|
70
|
+
else
|
71
|
+
footer << line.strip
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
handle_footer(footer) if footer.any?
|
76
|
+
|
77
|
+
@paragraphs.pop if @footers.any? || @breaking_changes.any?
|
78
|
+
end
|
79
|
+
|
80
|
+
def handle_footer(footer_lines = [])
|
81
|
+
footer = footer_lines.join("\n")
|
82
|
+
|
83
|
+
if footer.start_with?('BREAKING CHANGE:')
|
84
|
+
@breaking_changes << footer.delete_prefix('BREAKING CHANGE:').strip
|
85
|
+
else
|
86
|
+
@footers << footer
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def clean(message)
|
91
|
+
message.sub(/.*-----END PGP SIGNATURE-----\n\n/m, '')
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class MessageValidator
|
5
|
+
|
6
|
+
attr_reader :errors
|
7
|
+
|
8
|
+
def initialize(config, message)
|
9
|
+
@config = config
|
10
|
+
@message = message
|
11
|
+
@errors = []
|
12
|
+
validate_all
|
13
|
+
end
|
14
|
+
|
15
|
+
def valid?
|
16
|
+
@errors.empty?
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate_all
|
20
|
+
validate_valid_format
|
21
|
+
return unless @message.valid?
|
22
|
+
|
23
|
+
validate_type
|
24
|
+
validate_scope
|
25
|
+
validate_description_presence
|
26
|
+
validate_description_length
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def validate_valid_format
|
32
|
+
return if @message.valid?
|
33
|
+
|
34
|
+
errors << 'The commit message is not in a valid format'
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate_type
|
38
|
+
return if @message.type.nil?
|
39
|
+
return if @config.valid_type?(@message.type)
|
40
|
+
|
41
|
+
errors << "Type (#{@message.type}) is not valid"
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_scope
|
45
|
+
return if @message.scope.nil?
|
46
|
+
return if @config.valid_scope?(@message.scope)
|
47
|
+
|
48
|
+
errors << "Scope (#{@message.scope}) is not valid"
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_description_presence
|
52
|
+
return if @message.description&.size&.positive?
|
53
|
+
|
54
|
+
errors << 'A description (text after the type) must be provided'
|
55
|
+
end
|
56
|
+
|
57
|
+
def validate_description_length
|
58
|
+
return if @message.description.nil?
|
59
|
+
return if @message.description.size.zero?
|
60
|
+
return if @message.description.size <= @config.linting[:max_description_length]
|
61
|
+
|
62
|
+
errors << "Commit description must be less than #{@config.linting[:max_description_length]} " \
|
63
|
+
"characters (currently #{@message.description.size})"
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/formatters'
|
4
|
+
|
5
|
+
module Schmersion
|
6
|
+
class Releaser
|
7
|
+
|
8
|
+
COLORS = [:blue, :red, :yellow, :magenta, :green, :cyan].freeze
|
9
|
+
|
10
|
+
def initialize(repo, **options)
|
11
|
+
@repo = repo
|
12
|
+
@options = options
|
13
|
+
@exports = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def release
|
17
|
+
check_for_for_existing_version
|
18
|
+
generate_exports
|
19
|
+
preview_exports
|
20
|
+
save_exports
|
21
|
+
commit
|
22
|
+
tag
|
23
|
+
display_prompt
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def check_for_for_existing_version
|
29
|
+
if @repo.version?(version.version)
|
30
|
+
raise Error, "#{version.version} already exists in this repository"
|
31
|
+
end
|
32
|
+
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
def generate_exports
|
37
|
+
return if skip?(:export)
|
38
|
+
|
39
|
+
@exports = {}
|
40
|
+
@repo.config.exports.each do |formatter|
|
41
|
+
output = formatter.generate(@repo, version)
|
42
|
+
@exports[formatter] = output
|
43
|
+
end
|
44
|
+
@exports
|
45
|
+
end
|
46
|
+
|
47
|
+
def preview_exports
|
48
|
+
return if skip?(:export)
|
49
|
+
return unless dry_run?
|
50
|
+
|
51
|
+
@exports.each_with_index do |(formatter, output), index|
|
52
|
+
color = COLORS[index % COLORS.size]
|
53
|
+
puts formatter.filename.colorize(color: :white, background: color)
|
54
|
+
output.split("\n").each do |line|
|
55
|
+
print '> '.colorize(color: color)
|
56
|
+
puts line
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def save_exports
|
62
|
+
return if skip?(:export)
|
63
|
+
|
64
|
+
@exports.each do |formatter, output|
|
65
|
+
action 'save', formatter.filename do
|
66
|
+
formatter.insert(output)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def commit
|
72
|
+
return if skip?(:commit)
|
73
|
+
|
74
|
+
action 'commit', version.commit_message do
|
75
|
+
@repo.repo.reset
|
76
|
+
@exports.each_key do |formatter|
|
77
|
+
@repo.repo.add(formatter.filename)
|
78
|
+
end
|
79
|
+
@repo.repo.commit(version.commit_message, allow_empty: true)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def tag
|
84
|
+
return if skip?(:tag)
|
85
|
+
|
86
|
+
action 'tag', version.version.to_s do
|
87
|
+
@repo.repo.add_tag(version.version.to_s)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def display_prompt
|
92
|
+
puts
|
93
|
+
puts "Release of #{version.version} completed".white.on_green
|
94
|
+
|
95
|
+
return if skip?(:tag) && skip?(:commit)
|
96
|
+
|
97
|
+
print 'Now run '
|
98
|
+
print "git push --follow-tags origin #{@repo.current_branch}".cyan
|
99
|
+
puts ' to publish'
|
100
|
+
end
|
101
|
+
|
102
|
+
def version
|
103
|
+
@version ||= begin
|
104
|
+
@repo.pending_version(
|
105
|
+
override_version: @options[:version],
|
106
|
+
version_options: {
|
107
|
+
pre: @options[:pre],
|
108
|
+
breaking_change_not_major: @repo.config.version_options['breaking_change_not_major']
|
109
|
+
}
|
110
|
+
)[1]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def dry_run?
|
115
|
+
@options[:dry_run] == true
|
116
|
+
end
|
117
|
+
|
118
|
+
def action_color
|
119
|
+
dry_run? ? :magenta : :green
|
120
|
+
end
|
121
|
+
|
122
|
+
def action(action, text)
|
123
|
+
yield unless dry_run?
|
124
|
+
print "#{action}: ".colorize(action_color)
|
125
|
+
puts text
|
126
|
+
end
|
127
|
+
|
128
|
+
def skip?(type)
|
129
|
+
return false if @options[:skips].nil?
|
130
|
+
|
131
|
+
@options[:skips].include?(type)
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
require 'git'
|
5
|
+
require 'semantic'
|
6
|
+
require 'schmersion/commit_parser'
|
7
|
+
require 'schmersion/commit'
|
8
|
+
require 'schmersion/version'
|
9
|
+
require 'schmersion/config'
|
10
|
+
require 'schmersion/hosts'
|
11
|
+
|
12
|
+
module Schmersion
|
13
|
+
class Repo
|
14
|
+
|
15
|
+
attr_reader :path
|
16
|
+
attr_reader :repo
|
17
|
+
|
18
|
+
def initialize(path)
|
19
|
+
@path = path
|
20
|
+
@repo = Git.open(path)
|
21
|
+
rescue ArgumentError => e
|
22
|
+
if e.message =~ /path does not exist/
|
23
|
+
raise Error, "No git repository found at #{path}"
|
24
|
+
end
|
25
|
+
|
26
|
+
raise
|
27
|
+
end
|
28
|
+
|
29
|
+
def path_for(*join)
|
30
|
+
File.join(@path, *join)
|
31
|
+
end
|
32
|
+
|
33
|
+
def config
|
34
|
+
@config ||= load_config(['.schmersion.yaml', '.schmersion.yml'])
|
35
|
+
end
|
36
|
+
|
37
|
+
def origin
|
38
|
+
@origin ||= @repo.remotes.find { |r| r.name == 'origin' }&.url
|
39
|
+
end
|
40
|
+
|
41
|
+
def host
|
42
|
+
return nil if origin.nil?
|
43
|
+
|
44
|
+
Hosts.host_for_url(origin)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get the pending version for the currently checked out branch
|
48
|
+
# for the repository.
|
49
|
+
def pending_version(from: nil, to: 'HEAD', **options)
|
50
|
+
options[:version_options] ||= {}
|
51
|
+
|
52
|
+
if from.nil?
|
53
|
+
from_version = versions.last
|
54
|
+
else
|
55
|
+
from_version = versions.find { |v, _| v.to_s == from }
|
56
|
+
if from_version.nil?
|
57
|
+
raise Error, "Could not find existing version named #{from}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
if from_version
|
62
|
+
previous_version, previous_version_commit = from_version
|
63
|
+
end
|
64
|
+
|
65
|
+
parser = CommitParser.new(@repo, previous_version_commit&.ref || :start, to)
|
66
|
+
if v = options[:override_version]
|
67
|
+
begin
|
68
|
+
next_version = Semantic::Version.new(v)
|
69
|
+
rescue ArgumentError => e
|
70
|
+
if e.message =~ /not a valid SemVer/
|
71
|
+
raise Error, "'#{v}' is not a valid version"
|
72
|
+
end
|
73
|
+
|
74
|
+
raise
|
75
|
+
end
|
76
|
+
else
|
77
|
+
next_version = parser.next_version_after(previous_version, **options[:version_options])
|
78
|
+
end
|
79
|
+
|
80
|
+
[
|
81
|
+
previous_version,
|
82
|
+
Version.new(self, next_version, parser)
|
83
|
+
]
|
84
|
+
end
|
85
|
+
|
86
|
+
def commits(start_commit, end_commit, **options)
|
87
|
+
parser = CommitParser.new(@repo, start_commit, end_commit, **options)
|
88
|
+
parser.commits
|
89
|
+
end
|
90
|
+
|
91
|
+
def current_branch
|
92
|
+
@repo.branch.name
|
93
|
+
end
|
94
|
+
|
95
|
+
def version?(version)
|
96
|
+
@repo.tag(version.to_s).is_a?(Git::Object::Tag)
|
97
|
+
rescue Git::GitTagNameDoesNotExist
|
98
|
+
false
|
99
|
+
end
|
100
|
+
|
101
|
+
def versions
|
102
|
+
versions = @repo.tags.each_with_object([]) do |tag, array|
|
103
|
+
commit = @repo.gcommit(tag.sha)
|
104
|
+
version = Semantic::Version.new(tag.name)
|
105
|
+
array << [version, Commit.new(commit)]
|
106
|
+
rescue ArgumentError => e
|
107
|
+
raise unless e.message =~ /not a valid SemVer/
|
108
|
+
end
|
109
|
+
versions.sort_by { |_, c| c.date }
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def load_config(filenames)
|
115
|
+
filenames.each do |filename|
|
116
|
+
path = path_for(filename)
|
117
|
+
if File.file?(path)
|
118
|
+
return Config.new(::YAML.load_file(path))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
warn 'No config file was found, using defaults'
|
123
|
+
Config.new({})
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
|
5
|
+
VERSION_FILE_ROOT = File.expand_path('../../VERSION', __dir__)
|
6
|
+
if File.file?(VERSION_FILE_ROOT)
|
7
|
+
VERSION = File.read(VERSION_FILE_ROOT).strip.sub(/\Av/, '')
|
8
|
+
else
|
9
|
+
VERSION = '0.0.0.dev'
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Version
|
5
|
+
|
6
|
+
attr_reader :version
|
7
|
+
attr_reader :commit_parser
|
8
|
+
|
9
|
+
def initialize(repo, version, commit_parser)
|
10
|
+
@repo = repo
|
11
|
+
@version = version
|
12
|
+
@commit_parser = commit_parser
|
13
|
+
end
|
14
|
+
|
15
|
+
def commits
|
16
|
+
@commit_parser.commits
|
17
|
+
end
|
18
|
+
|
19
|
+
def start_commit
|
20
|
+
@commit_parser.start_commit
|
21
|
+
end
|
22
|
+
|
23
|
+
def end_commit
|
24
|
+
@commit_parser.end_commit
|
25
|
+
end
|
26
|
+
|
27
|
+
def commit_message
|
28
|
+
"chore(release): #{version}"
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|