Skip to content

Commit 56b50b8

Browse files
committed
Add automatic version numbering
1 parent 6f477c3 commit 56b50b8

File tree

8 files changed

+662
-2
lines changed

8 files changed

+662
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
* Adds automatic version number calculation. [#350]
1414

1515
### Bug Fixes
1616

lib/fastlane/plugin/wpmreleasetoolkit.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module Fastlane
44
module Wpmreleasetoolkit
55
# Return all .rb files inside the "actions" and "helper" directory
66
def self.all_classes
7-
Dir[File.expand_path('**/{actions,helper}/**/*.rb', File.dirname(__FILE__))]
7+
Dir[File.expand_path('**/{actions,helper,models}/**/*.rb', File.dirname(__FILE__))]
88
end
99
end
1010
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
module Fastlane
2+
module Helper
3+
class VersionManager
4+
def initialize(github_client:, git: nil)
5+
@git = git || Git.open(Dir.pwd)
6+
@github_client = github_client
7+
end
8+
9+
# Generate a prototype build name based on the current branch and commit.
10+
#
11+
# Takes optional `branch:` and `commit:` arguments to generate a build name
12+
# based on a different branch and commit.
13+
def prototype_build_name(branch: nil, commit: nil)
14+
branch ||= current_branch
15+
commit ||= current_commit
16+
17+
"#{branch}-#{commit[:sha][0, 7]}"
18+
end
19+
20+
# Generate a prototype build number based on the most recent commit.
21+
#
22+
# Takes an optional `commit:` argument to generate a build number based
23+
# on a different commit.
24+
def prototype_build_number(commit: nil)
25+
commit ||= current_commit
26+
commit[:date].to_i
27+
end
28+
29+
# Generate an alpha build name based on the current branch and commit.
30+
#
31+
# Takes optional `branch:` and `commit:` arguments to generate a build name
32+
# based on a different branch and commit.
33+
def alpha_build_name(branch: nil, commit: nil)
34+
branch ||= current_branch
35+
commit ||= current_commit
36+
37+
"#{branch}-#{commit[:sha][0, 7]}"
38+
end
39+
40+
# Generate an alpha number.
41+
#
42+
# Allows injecting a specific `DateTime` to derive the build number from
43+
def alpha_build_number(now: DateTime.now)
44+
now.to_i
45+
end
46+
47+
# Find the newest rc of a specific version in a given GitHub repository.
48+
def newest_rc_for_version(version, repository:)
49+
@github_client
50+
.tags(repository)
51+
.map { |t| Version.create(t[:name]) }
52+
.compact
53+
.filter { |v| v.is_different_rc_of(version) }
54+
.filter(&:prerelease?)
55+
.sort
56+
.reverse
57+
.first
58+
end
59+
60+
# Given the current version of an app and its Git Repository,
61+
# use the existing tags to figure out which RC version should be
62+
# the next one.
63+
def next_rc_for_version(version, repository:)
64+
most_recent_rc_version = newest_rc_for_version(version, repository: repository)
65+
66+
# If there is no RC tag, this must be the first one ever
67+
return version.next_rc_version if most_recent_rc_version.nil?
68+
69+
# If we have a previous RC for this version, we can just bump it
70+
most_recent_rc_version.next_rc_version
71+
end
72+
73+
private
74+
75+
# Get the most recent commit on the current branch of the Git repository
76+
def current_commit
77+
@git.log.first
78+
end
79+
80+
# Get the most current branch of the Git repository
81+
def current_branch
82+
@git.current_branch
83+
end
84+
end
85+
end
86+
end
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
module Fastlane
2+
module Helper
3+
RC_DELIMITERS = %w[
4+
rc
5+
beta
6+
b
7+
].freeze
8+
9+
class Version
10+
include Comparable
11+
attr :major, :minor, :patch, :rc
12+
13+
def initialize(major:, minor:, patch: 0, rc_number: nil)
14+
@major = major
15+
@minor = minor
16+
@patch = patch
17+
@rc = rc_number
18+
end
19+
20+
# Create a new Version object based on a given string.
21+
#
22+
# Can parse a variety of different two, three, and four-segment version numbers,
23+
# including:
24+
# - x.y
25+
# - x.yrc1
26+
# - x.y.rc1
27+
# - x.y-rc1
28+
# - x.y.rc.1
29+
# - x.y-rc-1
30+
# - x.y.z
31+
# - x.y.zrc1
32+
# - x.y.z.rc1
33+
# - x.y.z-rc1
34+
# - x.y.z.rc.1
35+
# - x.y.z-rc-1
36+
# - Any of the above with `v` prepended
37+
#
38+
def self.create(string)
39+
string = string.downcase
40+
string = string.delete_prefix('v') if string.start_with?('v')
41+
42+
components = string
43+
.split('.')
44+
.map { |component| component.remove('-') }
45+
.delete_if { |component| component == 'rc' }
46+
47+
return nil if components.length < 2
48+
49+
# Turn RC version codes into simple versions
50+
if components.last.include? 'rc'
51+
rc_segments = VersionHelpers.rc_segments_from_string(components.last)
52+
components.delete_at(components.length - 1)
53+
components = VersionHelpers.combine_components_and_rc_segments(components, rc_segments)
54+
end
55+
56+
# Validate our work
57+
raise if components.any? { |component| !VersionHelpers.string_is_valid_int(component) }
58+
59+
# If this is a simple version string, process it early
60+
major = components.first.to_i
61+
minor = components.second.to_i
62+
patch = components.third.to_i
63+
64+
# Simple two-segment version numbers can exit here
65+
return Version.new(major: major, minor: minor) if components.length == 2
66+
67+
# Simple three-segment version numbers can exit here
68+
return Version.new(major: major, minor: minor, patch: patch) if components.length == 3
69+
70+
# Simple four-segment version numbers can exit here
71+
return Version.new(major: major, minor: minor, patch: patch, rc_number: components.fourth.to_i) if components.length == 4
72+
end
73+
74+
# Create a new Version object based on a given string.
75+
#
76+
# Raises if the string is invalid
77+
def self.create!(string)
78+
version = create(string)
79+
raise "Invalid Version: #{string}" if version.nil?
80+
81+
version
82+
end
83+
84+
# Returns a formatted string suitable for use as an Android Version Name
85+
def android_version_name
86+
return [@major, @minor].join('.') if @patch.zero? && @rc.nil?
87+
return [@major, @minor, @patch].join('.') if !@patch.zero? && rc.nil?
88+
return [@major, "#{@minor}-rc-#{@rc}"].join('.') if @patch.zero? && !rc.nil?
89+
90+
return [@major, @minor, "#{@patch}-rc-#{@rc}"].join('.')
91+
end
92+
93+
# Returns a formatted string suitable for use as an Android Version Code
94+
def android_version_code(prefix: 1)
95+
[
96+
'1',
97+
@major,
98+
format('%02d', @minor),
99+
format('%02d', @patch),
100+
format('%02d', @rc || 0),
101+
].join
102+
end
103+
104+
# Returns a formatted string suitable for use as an iOS Version Number
105+
def ios_version_number
106+
return [@major, @minor, @patch, @rc || 0].join('.')
107+
end
108+
109+
# Returns a string suitable for comparing two version objects
110+
#
111+
# This method has no version number padding, so its likely to have collisions
112+
def raw_version_code
113+
[@major, @minor, @patch, @rc || 0].join.to_i
114+
end
115+
116+
# Is this version number a patch version?
117+
def patch?
118+
!@patch.zero?
119+
end
120+
121+
# Is this version number a prerelease version?
122+
def prerelease?
123+
!@rc.nil?
124+
end
125+
126+
# Derive the next major version from this version number
127+
def next_major_version
128+
Version.new(
129+
major: @major + 1,
130+
minor: 0
131+
)
132+
end
133+
134+
# Derive the next minor version from this version number
135+
def next_minor_version
136+
major = @major
137+
minor = @minor
138+
139+
if minor == 9
140+
major += 1
141+
minor = 0
142+
else
143+
minor += 1
144+
end
145+
146+
Version.new(
147+
major: major,
148+
minor: minor
149+
)
150+
end
151+
152+
# Derive the next patch version from this version number
153+
def next_patch_version
154+
Version.new(
155+
major: @major,
156+
minor: @minor,
157+
patch: @patch + 1
158+
)
159+
end
160+
161+
# Derive the next rc version from this version number
162+
def next_rc_version
163+
rc = @rc
164+
rc = 0 if rc.nil?
165+
166+
Version.new(
167+
major: @major,
168+
minor: @minor,
169+
patch: @patch,
170+
rc_number: rc + 1
171+
)
172+
end
173+
174+
# Is this version the same as another version, just with different RC codes?
175+
def is_different_rc_of(other)
176+
return false unless other.is_a?(Version)
177+
178+
return other.major == @major && other.minor == @minor && other.patch == @patch
179+
end
180+
181+
# Is this version the same as another version, just with a different patch version?
182+
def is_different_patch_of(other)
183+
return false unless other.is_a?(Version)
184+
185+
return other.major == @major && other.minor == @minor
186+
end
187+
188+
def ==(other)
189+
return false unless other.is_a?(Version)
190+
191+
raw_version_code == other.raw_version_code
192+
end
193+
194+
def equal?(other)
195+
self == other
196+
end
197+
198+
def <=>(other)
199+
raw_version_code <=> other.raw_version_code
200+
end
201+
end
202+
203+
# A collection of helpers for the `Version.create` method that extract some of the tricky code
204+
# that's nice to be able to test in isolation – in practice, this is private API and you *probably*
205+
# don't want to use it for other things.
206+
module VersionHelpers
207+
# Determines whether the given string is a valid integer.
208+
#
209+
# Examples:
210+
# - 00 => true
211+
# - 01 => true
212+
# - 1 => true
213+
# - rc => false
214+
# See the `version_helpers_spec` for more test cases.
215+
#
216+
# @param string String The string to check.
217+
# @return bool `true` if the given string is a valid integer. `false` if not.
218+
def self.string_is_valid_int(string)
219+
return true if string.count('0') == string.length
220+
221+
# Remove any leading zeros
222+
string = string.delete_prefix('0')
223+
224+
return string.to_i.to_s == string
225+
end
226+
227+
# Extracts all integers (delimited by anything non-integer value) from a given string
228+
#
229+
# @param string String The string to check.
230+
# @return [int] The integers contained within the string
231+
def self.extract_ints_from_string(string)
232+
string.scan(/\d+/)
233+
end
234+
235+
# Parses release candidate number (and potentially minor or patch version depending on how the
236+
# version code is formatted) from a given string. This can take a variety of forms because the
237+
# release candidate segment of a version string can be formatted in a lot of different ways.
238+
#
239+
# Examples:
240+
# - 00 => ['0']
241+
# - rc1 => ['1']
242+
# - 5rc1 => ['5','1']
243+
# See the `version_helpers_spec` for more test cases.
244+
#
245+
# @param string String The string to parse.
246+
# @return [string] The leading and trailing digits from the version segment string
247+
def self.rc_segments_from_string(string)
248+
# If the string is all zeros, return zero
249+
return ['0'] if string.scan(/0/).length == string.length
250+
251+
extract_ints_from_string(string)
252+
end
253+
254+
# Combines the non-RC version string components with the RC segments extracted by `rc_segments_from_string`.
255+
#
256+
# Because this method needs to be able to assemble the version segments and release candidate segments into a
257+
# coherent version based on a variety of input formats, the implementation looks pretty complex, but it's covered
258+
# by a comprehensive test suite to validate that it does, in fact, work.
259+
#
260+
# Examples:
261+
# - [1.0], [1] => ['1','0', '0', '1']
262+
# - [1.0], [2,1] => ['1','0', '2', '1']
263+
# See the `version_helpers_spec` for more test cases.
264+
#
265+
# @param components [string] The version string components (without the RC segments)
266+
# @param rc_segments [string] The return value from `rc_segments_from_string`
267+
# @return [string] An array of stringified integer version components in `major.minor.patch.rc` order
268+
def self.combine_components_and_rc_segments(components, rc_segments)
269+
case true # rubocop:disable Lint/LiteralAsCondition
270+
when components.length == 1 && rc_segments.length == 2
271+
return [components.first, rc_segments.first, '0', rc_segments.last]
272+
when components.length == 2 && rc_segments.length == 1
273+
return [components.first, components.second, '0', rc_segments.first]
274+
when components.length == 2 && rc_segments.length == 2
275+
return [components.first, components.second, rc_segments.first, rc_segments.last]
276+
when components.length == 3 && rc_segments.length == 1
277+
return [components.first, components.second, components.third, rc_segments.first]
278+
end
279+
280+
raise "Invalid components: #{components.inspect} or rc_segments: #{rc_segments.inspect}"
281+
end
282+
end
283+
end
284+
end

spec/github_helper_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,6 @@ def mock_comment(body: '<!-- REUSE_ID: test-id --> Test', user_id: 1234)
147147
instance_double('Comment', id: 1234, body: body, user: instance_double('User', id: user_id))
148148
end
149149
end
150+
151+
describe ''
150152
end

0 commit comments

Comments
 (0)