Skip to content

Commit 2be3704

Browse files
authored
Add ContinuousBuildCodeFormatter for continuous-trunk Android versionCodes (#735)
2 parents de7ff74 + 3a5a1cc commit 2be3704

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ _None_
1111
### New Features
1212

1313
- Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. [#733]
14+
- Added `ContinuousBuildCodeFormatter`, which derives an Android Play Store `versionCode` for a "continuous trunk" release model as `(major * 10 + minor) * 10^build_digits + build_number` (default `build_digits: 6`). It encodes a high-cardinality, monotonically increasing build number (e.g. a Buildkite build number) that `DerivedBuildCodeFormatter` cannot hold, validates that `minor <= 9`, and guards against exceeding the Play Store's maximum `versionCode`. [#735]
1415

1516
### Bug Fixes
1617

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
module Fastlane
4+
module Wpmreleasetoolkit
5+
module Versioning
6+
# Google Play Store's maximum allowed versionCode.
7+
MAX_PLAY_STORE_VERSION_CODE = 2_100_000_000
8+
9+
# The `ContinuousBuildCodeFormatter` derives an Android Play Store `versionCode` for a
10+
# "continuous trunk" release model, where the low-order term is a high-cardinality,
11+
# monotonically increasing build number (e.g. a Buildkite build number).
12+
#
13+
# The build code is computed as:
14+
#
15+
# versionCode = (major * 10 + minor) * 10^build_digits + build_number
16+
#
17+
# It takes `major`, `minor`, and `build_number` as explicit arguments rather than an
18+
# `AppVersion`, because the inputs come from different sources and an `AppVersion` does not
19+
# model them: the marketing `major`/`minor` come from the parsed version, while `build_number`
20+
# is an independent CI counter (e.g. `BUILDKITE_BUILD_NUMBER`). Notably, `AppVersion#build_number`
21+
# means something else in this domain (the RC/beta iteration counter, e.g. `-rc-1`), so taking an
22+
# `AppVersion` here would invite reading the wrong field. There is also no `patch`: in a
23+
# continuous-trunk model the build number strictly orders every build and subsumes patch's
24+
# ordering role (hotfixes get a new build number, not a patch digit in the code).
25+
#
26+
# Because the build number is globally monotonic and the version prefix only ever increases,
27+
# the resulting code is always strictly increasing — even if the build number eventually
28+
# exceeds `10^build_digits` (which only costs human-readability, not ordering). The only hard
29+
# correctness constraint is staying at or below the Play Store's max versionCode.
30+
#
31+
# Unlike `DerivedBuildCodeFormatter` (fixed-width string concatenation capped at 8 total digits
32+
# and 3 digits per component, i.e. build <= 999), this formatter can hold a large build number.
33+
# The two formatters target different release models; this one does not replace the other.
34+
class ContinuousBuildCodeFormatter
35+
# @param [Integer] build_digits Number of digits reserved for the build number, which sets the
36+
# multiplier applied to the `major * 10 + minor` prefix (multiplier = 10^build_digits).
37+
# Must be a positive integer. Defaults to 6 (multiplier = 1_000_000).
38+
#
39+
def initialize(build_digits: 6)
40+
validate_build_digits!(build_digits)
41+
@build_digits = build_digits
42+
end
43+
44+
# Derive the build code (Android `versionCode`).
45+
#
46+
# @param [Integer] major The major (marketing) version number.
47+
# @param [Integer] minor The minor (marketing) version number. Must be 9 or lower.
48+
# @param [Integer] build_number A high-cardinality, monotonically increasing build number
49+
# (e.g. a Buildkite build number). This is a CI counter, not `AppVersion#build_number`.
50+
#
51+
# @return [Integer] The derived `versionCode`.
52+
#
53+
def build_code(major:, minor:, build_number:)
54+
# Validate up front so bad input (e.g. strings from env vars or file reads) raises a
55+
# user-friendly error rather than an opaque `TypeError` from the arithmetic below.
56+
validate_component!('major', major)
57+
validate_component!('minor', minor)
58+
validate_component!('build_number', build_number)
59+
60+
# `major * 10 + minor` is only unambiguous while minor is a single digit.
61+
if minor > 9
62+
UI.user_error!("Minor version (#{minor}) must be 9 or lower to derive an unambiguous build code with `#{self.class.name}`")
63+
end
64+
65+
prefix = (major * 10) + minor
66+
code = (prefix * (10**@build_digits)) + build_number
67+
68+
# Sanity check: Play Store versionCodes must be positive integers.
69+
if code <= 0
70+
UI.user_error!("Derived build code (#{code}) must be a positive integer")
71+
end
72+
73+
if code > MAX_PLAY_STORE_VERSION_CODE
74+
UI.user_error!("Derived build code (#{code}) exceeds the maximum allowed Play Store versionCode (#{MAX_PLAY_STORE_VERSION_CODE})")
75+
end
76+
77+
code
78+
end
79+
80+
private
81+
82+
# Validates that a version component is a non-negative integer.
83+
#
84+
# @param [String] name The component name, used in the error message
85+
# @param [Integer] value The value to validate
86+
#
87+
# @raise [StandardError] If the value is not a non-negative integer
88+
#
89+
def validate_component!(name, value)
90+
unless value.is_a?(Integer)
91+
UI.user_error!("`#{name}` must be an integer, got: #{value.class}")
92+
end
93+
94+
return unless value.negative?
95+
96+
UI.user_error!("`#{name}` must be a non-negative integer, got: #{value}")
97+
end
98+
99+
# Validates that `build_digits` is a positive integer.
100+
#
101+
# @param [Integer] build_digits The build digit count to validate
102+
#
103+
# @raise [StandardError] If the value is not a positive integer
104+
#
105+
def validate_build_digits!(build_digits)
106+
unless build_digits.is_a?(Integer)
107+
UI.user_error!("`build_digits` must be an integer, got: #{build_digits.class}")
108+
end
109+
110+
return if build_digits.positive?
111+
112+
UI.user_error!("`build_digits` must be a positive integer, got: #{build_digits}")
113+
end
114+
end
115+
end
116+
end
117+
end
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Fastlane::Wpmreleasetoolkit::Versioning::ContinuousBuildCodeFormatter do
6+
describe 'derives a versionCode' do
7+
it 'encodes major, minor and build number with the default build_digits (6)' do
8+
expect(described_class.new.build_code(major: 26, minor: 9, build_number: 84_231)).to eq(269_084_231)
9+
end
10+
11+
it 'returns an Integer' do
12+
expect(described_class.new.build_code(major: 26, minor: 9, build_number: 84_231)).to be_a(Integer)
13+
end
14+
end
15+
16+
describe 'monotonicity' do
17+
it 'increases with the build number within the same version' do
18+
formatter = described_class.new
19+
expect(formatter.build_code(major: 26, minor: 9, build_number: 84_232))
20+
.to be > formatter.build_code(major: 26, minor: 9, build_number: 84_231)
21+
end
22+
23+
it 'increases across a version bump even when the build number also increases' do
24+
formatter = described_class.new
25+
expect(formatter.build_code(major: 27, minor: 0, build_number: 84_232))
26+
.to be > formatter.build_code(major: 26, minor: 9, build_number: 84_231)
27+
end
28+
end
29+
30+
describe 'custom build_digits' do
31+
it 'applies the configured multiplier (10^build_digits)' do
32+
# (2 * 10 + 6) * 10^4 + 123 = 26 * 10_000 + 123 = 260_123
33+
expect(described_class.new(build_digits: 4).build_code(major: 2, minor: 6, build_number: 123)).to eq(260_123)
34+
end
35+
end
36+
37+
# The build number is allowed to exceed 10^build_digits. The docstring guarantees ordering still
38+
# holds in that case (overflow "only costs human-readability, not ordering") *because* the build
39+
# number is globally monotonic. These lock that in — and guard against a future change such as
40+
# masking the build number with `% 10**build_digits`, which would silently break ordering.
41+
context 'when the build number overflows its reserved digits (10^build_digits)' do
42+
it 'still increases within the same version across the overflow boundary' do
43+
formatter = described_class.new(build_digits: 4) # 10^4 = 10_000
44+
# 269 * 10_000 + 10_000 = 2_700_000 vs 269 * 10_000 + 9_999 = 2_699_999
45+
expect(formatter.build_code(major: 26, minor: 9, build_number: 10_000))
46+
.to be > formatter.build_code(major: 26, minor: 9, build_number: 9_999)
47+
end
48+
49+
it 'still increases across a version bump while both build numbers are past the overflow point' do
50+
formatter = described_class.new(build_digits: 4) # 10^4 = 10_000
51+
# 270 * 10_000 + 25_001 = 2_725_001 vs 269 * 10_000 + 25_000 = 2_715_000
52+
expect(formatter.build_code(major: 27, minor: 0, build_number: 25_001))
53+
.to be > formatter.build_code(major: 26, minor: 9, build_number: 25_000)
54+
end
55+
56+
it 'can render identically to a later version once overflowed — the documented readability cost, which is harmless under a monotonic build number' do
57+
formatter = described_class.new(build_digits: 4) # 10^4 = 10_000
58+
# Overflow makes the digits ambiguous: 26.9 build 10_000 and 27.0 build 0 both come out as 2_700_000.
59+
# That's the "only costs human-readability" caveat — and it's safe because a globally monotonic
60+
# build number means (27, 0, build: 0) never *follows* (26, 9, build: 10_000); the counter only goes up.
61+
expect(formatter.build_code(major: 26, minor: 9, build_number: 10_000))
62+
.to eq(formatter.build_code(major: 27, minor: 0, build_number: 0))
63+
end
64+
end
65+
66+
describe 'validation' do
67+
it 'raises when minor is greater than 9' do
68+
expect { described_class.new.build_code(major: 26, minor: 10, build_number: 1) }
69+
.to raise_error(/Minor version \(10\) must be 9 or lower/)
70+
end
71+
72+
it 'raises when the derived code exceeds the Play Store maximum' do
73+
# (210 * 10 + 0) * 10^6 = 2_100_000_000; +1 tips over the cap.
74+
expect { described_class.new.build_code(major: 210, minor: 0, build_number: 1) }
75+
.to raise_error(/exceeds the maximum allowed Play Store versionCode \(2100000000\)/)
76+
end
77+
78+
it 'does not raise when the derived code is exactly at the Play Store maximum' do
79+
expect(described_class.new.build_code(major: 210, minor: 0, build_number: 0)).to eq(2_100_000_000)
80+
end
81+
82+
it 'raises when the derived code is not positive (all-zeros input)' do
83+
expect { described_class.new.build_code(major: 0, minor: 0, build_number: 0) }
84+
.to raise_error(/Derived build code \(0\) must be a positive integer/)
85+
end
86+
87+
it 'rejects a non-integer build_digits' do
88+
expect { described_class.new(build_digits: '6') }
89+
.to raise_error(/`build_digits` must be an integer, got: String/)
90+
end
91+
92+
it 'rejects a non-positive build_digits' do
93+
expect { described_class.new(build_digits: 0) }
94+
.to raise_error(/`build_digits` must be a positive integer, got: 0/)
95+
end
96+
97+
%w[major minor build_number].each do |component|
98+
it "rejects a non-integer #{component} with a user-friendly error" do
99+
args = { major: 26, minor: 9, build_number: 84_231 }
100+
args[component.to_sym] = '1' # e.g. an unparsed value from an env var or file read
101+
expect { described_class.new.build_code(**args) }
102+
.to raise_error(/`#{component}` must be an integer, got: String/)
103+
end
104+
105+
it "rejects a negative #{component}" do
106+
args = { major: 26, minor: 9, build_number: 84_231 }
107+
args[component.to_sym] = -1
108+
expect { described_class.new.build_code(**args) }
109+
.to raise_error(/`#{component}` must be a non-negative integer, got: -1/)
110+
end
111+
end
112+
end
113+
end

0 commit comments

Comments
 (0)