-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add semver 2 versioning in dependabot common (#10434)
* Add semver 2 versioning in dependabot common Why: To be used as a standard for ecosystems that do not have a versioning standard. * Fix sorbet errors and address PR comments
- Loading branch information
Showing
2 changed files
with
348 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
# typed: strong | ||
# frozen_string_literal: true | ||
|
||
require "sorbet-runtime" | ||
|
||
# See https://semver.org/spec/v2.0.0.html for semver 2 details | ||
# | ||
module Dependabot | ||
class SemVersion2 | ||
extend T::Sig | ||
extend T::Helpers | ||
include Comparable | ||
|
||
SEMVER2_REGEX = /^ | ||
(0|[1-9]\d*)\. # major | ||
(0|[1-9]\d*)\. # minor | ||
(0|[1-9]\d*) # patch | ||
(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))? # pre release | ||
(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? # build metadata | ||
$/x | ||
|
||
sig { returns(String) } | ||
attr_accessor :major | ||
|
||
sig { returns(String) } | ||
attr_accessor :minor | ||
|
||
sig { returns(String) } | ||
attr_accessor :patch | ||
|
||
sig { returns(T.nilable(String)) } | ||
attr_accessor :build | ||
|
||
sig { returns(T.nilable(String)) } | ||
attr_accessor :prerelease | ||
|
||
sig { params(version: String).void } | ||
def initialize(version) | ||
tokens = parse(version) | ||
@major = T.let(T.must(tokens[:major]), String) | ||
@minor = T.let(T.must(tokens[:minor]), String) | ||
@patch = T.let(T.must(tokens[:patch]), String) | ||
@build = T.let(tokens[:build], T.nilable(String)) | ||
@prerelease = T.let(tokens[:prerelease], T.nilable(String)) | ||
end | ||
|
||
sig { returns(T::Boolean) } | ||
def prerelease? | ||
!!prerelease | ||
end | ||
|
||
sig { returns(String) } | ||
def to_s | ||
value = [major, minor, patch].join(".") | ||
value += "-#{prerelease}" if prerelease | ||
value += "+#{build}" if build | ||
value | ||
end | ||
|
||
sig { returns(String) } | ||
def inspect | ||
"#<#{self.class} #{self}>" | ||
end | ||
|
||
sig { params(other: ::Dependabot::SemVersion2).returns(T::Boolean) } | ||
def eql?(other) | ||
other.is_a?(self.class) && to_s == other.to_s | ||
end | ||
|
||
sig { params(other: ::Dependabot::SemVersion2).returns(Integer) } | ||
def <=>(other) | ||
result = major.to_i <=> other.major.to_i | ||
return result unless result.zero? | ||
|
||
result = minor.to_i <=> other.minor.to_i | ||
return result unless result.zero? | ||
|
||
result = patch.to_i <=> other.patch.to_i | ||
return result unless result.zero? | ||
|
||
compare_prereleases(prerelease, other.prerelease) | ||
end | ||
|
||
sig { params(version: T.nilable(String)).returns(T::Boolean) } | ||
def self.correct?(version) | ||
return false if version.nil? | ||
|
||
version.match?(SEMVER2_REGEX) | ||
end | ||
|
||
private | ||
|
||
sig { params(version: String).returns(T::Hash[Symbol, T.nilable(String)]) } | ||
def parse(version) | ||
match = version.match(SEMVER2_REGEX) | ||
raise ArgumentError, "Malformed version number string #{version}" unless match | ||
|
||
major, minor, patch, prerelease, build = match.captures | ||
|
||
{ major: major, minor: minor, patch: patch, prerelease: prerelease, build: build } | ||
end | ||
|
||
sig { params(prerelease1: T.nilable(String), prerelease2: T.nilable(String)).returns(Integer) } | ||
def compare_prereleases(prerelease1, prerelease2) # rubocop:disable Metrics/PerceivedComplexity | ||
return 0 if prerelease1.nil? && prerelease2.nil? | ||
return -1 if prerelease2.nil? | ||
return 1 if prerelease1.nil? | ||
|
||
prerelease1_tokens = prerelease1.split(".") | ||
prerelease2_tokens = prerelease2.split(".") | ||
|
||
prerelease1_tokens.zip(prerelease2_tokens) do |t1, t2| | ||
return 1 if t2.nil? # t1 is more specific e.g. 1.0.0-rc1.1 vs 1.0.0-rc1 | ||
|
||
if t1 =~ /^\d+$/ && t2 =~ /^\d+$/ | ||
# t1 and t2 are both ints so compare them as such | ||
a = t1.to_i | ||
b = t2.to_i | ||
compare = a <=> b | ||
return compare unless compare.zero? | ||
end | ||
|
||
comp = t1 <=> t2 | ||
return T.must(comp) unless T.must(comp).zero? | ||
end | ||
|
||
# prereleases are equal or prerelease2 is more specific e.g. 1.0.0-rc1 vs 1.0.0-rc1.1 | ||
prerelease1_tokens.length == prerelease2_tokens.length ? 0 : -1 | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,217 @@ | ||
# typed: true | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
require "dependabot/sem_version2" | ||
|
||
RSpec.describe Dependabot::SemVersion2 do | ||
subject(:version) { described_class.new(version_string) } | ||
|
||
let(:valid_versions) do | ||
%w( 0.0.4 1.2.3 10.20.30 1.1.2-prerelease+meta 1.1.2+meta 1.1.2+meta-valid 1.0.0-alpha | ||
1.0.0-beta 1.0.0-alpha.beta 1.0.0-alpha.beta.1 1.0.0-alpha.1 1.0.0-alpha0.valid | ||
1.0.0-alpha.0valid 1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay | ||
1.0.0-rc.1+build.1 2.0.0-rc.1+build.123 1.2.3-beta 10.2.3-DEV-SNAPSHOT | ||
1.2.3-SNAPSHOT-123 2.0.0+build.1848 2.0.1-alpha.1227 1.0.0-alpha+beta | ||
1.2.3----RC-SNAPSHOT.12.9.1--.12+788 1.2.3----R-S.12.9.1--.12+meta | ||
1.2.3----RC-SNAPSHOT.12.9.1--.12 1.0.0+0.build.1-rc.10000aaa-kk-0.1 | ||
9999999.999999999.99999999 1.0.0-0A.is.legal) | ||
end | ||
|
||
let(:invalid_versions) do | ||
%w(1 1.2 1.2.3-0123 1.2.3-0123.0123 1.1.2+.123 +invalid -invalid -invalid+invalid -invalid.01 alpha alpha.beta | ||
alpha.beta.1 alpha.1 alpha+beta alpha_beta alpha. alpha.. beta 1.0.0-alpha_beta -alpha. 1.0.0-alpha.. | ||
1.0.0-alpha..1 1.0.0-alpha...1 1.0.0-alpha....1 1.0.0-alpha.....1 1.0.0-alpha......1 1.0.0-alpha.......1 | ||
01.1.1 1.01.1 1.1.01 1.2.3.DEV 1.2-SNAPSHOT 1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788 1.2-RC-SNAPSHOT | ||
-1.0.3-gamma+b7718 +justmeta 9.8.7+meta+meta 9.8.7-whatever+meta+meta | ||
999.9999.99999----RC-SNAPSHOT.12.09.1----..12) | ||
end | ||
|
||
describe "#initialize" do | ||
it "raises an error when the version is invalid" do | ||
invalid_versions.each do |version| | ||
error_msg = "Malformed version number string #{version}" | ||
expect { described_class.new(version) }.to raise_error(ArgumentError, error_msg) | ||
end | ||
end | ||
|
||
context "with an empty version" do | ||
let(:version_string) { "" } | ||
let(:error_msg) { "Malformed version number string " } | ||
|
||
it "raises an error" do | ||
expect { version }.to raise_error(ArgumentError, error_msg) | ||
end | ||
end | ||
end | ||
|
||
describe "to_s" do | ||
it "returns the correct value" do | ||
valid_versions.each do |version| | ||
expect(described_class.new(version).to_s).to eq(version) | ||
end | ||
end | ||
end | ||
|
||
describe "#inspect" do | ||
subject { described_class.new(version_string).inspect } | ||
|
||
let(:version_string) { "1.0.0+build1" } | ||
|
||
it { is_expected.to eq("#<#{described_class} #{version_string}>") } | ||
end | ||
|
||
describe "#eql?" do | ||
let(:first) { described_class.new("1.2.3-rc.1+build1") } | ||
let(:second) { described_class.new("1.2.3-rc.1+build1") } | ||
|
||
it "returns true for equal semver values" do | ||
expect(first).to eql(second) | ||
end | ||
end | ||
|
||
describe "#<=>" do | ||
it "sorts version strings semantically" do | ||
versions = [] | ||
|
||
versions << described_class.new("1.0.0-alpha") | ||
versions << described_class.new("1.0.0-alpha.1") | ||
versions << described_class.new("1.0.0-alpha.1.beta.gamma") | ||
versions << described_class.new("1.0.0-alpha.beta") | ||
versions << described_class.new("1.0.0-alpha.beta.1") | ||
versions << described_class.new("1.0.0-beta") | ||
versions << described_class.new("1.0.0-beta.2") | ||
versions << described_class.new("1.0.0-beta.11") | ||
versions << described_class.new("1.0.0-rc.1") | ||
versions << described_class.new("1.0.0") | ||
expect(versions.shuffle.sort).to eq(versions) | ||
end | ||
|
||
context "when comparing numerical prereleases" do | ||
let(:first) { described_class.new("1.0.0-rc.2") } | ||
let(:second) { described_class.new("1.0.0-rc.10") } | ||
|
||
it "compares numerically" do | ||
expect(first <=> second).to eq(-1) | ||
expect(second <=> first).to eq(1) | ||
end | ||
end | ||
|
||
context "when comparing numerical prereleases" do | ||
let(:first) { described_class.new("1.0.0-rc.2") } | ||
let(:second) { described_class.new("1.0.0-rc.2.1") } | ||
|
||
it "compares numerically" do | ||
expect(first <=> second).to eq(-1) | ||
expect(second <=> first).to eq(1) | ||
end | ||
end | ||
|
||
context "when the versions are equal" do | ||
let(:first) { described_class.new("1.0.0-rc.2") } | ||
let(:second) { described_class.new("1.0.0-rc.2") } | ||
|
||
it "returns 0" do | ||
expect(first <=> second).to eq(0) | ||
expect(second <=> first).to eq(0) | ||
end | ||
end | ||
|
||
context "when comparing alphanumerical prereleases" do | ||
let(:first) { described_class.new("1.0.0-alpha10") } | ||
let(:second) { described_class.new("1.0.0-alpha2") } | ||
|
||
it "compares lexicographically" do | ||
expect(first <=> second).to eq(-1) | ||
expect(second <=> first).to eq(1) | ||
end | ||
end | ||
|
||
context "when comparing versions that contain build data" do | ||
let(:first) { described_class.new("1.0.0+build-123") } | ||
let(:second) { described_class.new("1.0.0+build-456") } | ||
|
||
it "ignores build metadata" do | ||
expect(first <=> second).to eq(0) | ||
end | ||
end | ||
end | ||
|
||
describe "#prerelease?" do | ||
subject { version.prerelease? } | ||
|
||
context "with an alpha" do | ||
let(:version_string) { "1.0.0-alpha" } | ||
|
||
it { is_expected.to be(true) } | ||
end | ||
|
||
context "with a capitalised alpha" do | ||
let(:version_string) { "1.0.0-Alpha" } | ||
|
||
it { is_expected.to be(true) } | ||
end | ||
|
||
context "with a dev token" do | ||
let(:version_string) { "1.2.1-dev-65" } | ||
|
||
it { is_expected.to be(true) } | ||
end | ||
|
||
context "with a 'pre' pre-release separated with a -" do | ||
let(:version_string) { "2.10.0-pre0" } | ||
|
||
it { is_expected.to be(true) } | ||
end | ||
|
||
context "with a release" do | ||
let(:version_string) { "1.0.0" } | ||
|
||
it { is_expected.to be(false) } | ||
end | ||
|
||
context "with a + separated alphanumeric build identifier" do | ||
let(:version_string) { "1.0.0+build1" } | ||
|
||
it { is_expected.to be(false) } | ||
end | ||
|
||
context "with an 'alpha' separated by a -" do | ||
let(:version_string) { "1.0.0-alpha+001" } | ||
|
||
it { is_expected.to be(true) } | ||
end | ||
end | ||
|
||
describe ".correct?" do | ||
subject { described_class.correct?(version_string) } | ||
|
||
context "with a nil version" do | ||
let(:version_string) { nil } | ||
|
||
it { is_expected.to be(false) } | ||
end | ||
|
||
context "with an empty version" do | ||
let(:version_string) { "" } | ||
|
||
it { is_expected.to be(false) } | ||
end | ||
|
||
context "with valid semver2 strings" do | ||
it "returns true" do | ||
valid_versions.each do |version| | ||
expect(described_class.correct?(version)).to be(true) | ||
end | ||
end | ||
end | ||
|
||
context "with invalid semver2 strings" do | ||
it "returns false" do | ||
invalid_versions.each do |version| | ||
expect(described_class.correct?(version)).to be(false) | ||
end | ||
end | ||
end | ||
end | ||
end |