1 <#
2 .DESCRIPTION
3 Parses a semver version string into its components and supports operations around it that we use for versioning our packages.
4 
5 See https://azure.github.io/azure-sdk/policies_releases.html#package-versioning
6 
7 Example: 1.2.3-beta.4
8 Components: Major.Minor.Patch-PrereleaseLabel.PrereleaseNumber
9 
10 Example: 1.2.3-alpha.20200828.4
11 Components: Major.Minor.Patch-PrereleaseLabel.PrereleaseNumber.BuildNumber
12 
13 Note: A builtin Powershell version of SemVer exists in 'System.Management.Automation'. At this time, it does not parsing of PrereleaseNumber. It's name is also type accelerated to 'SemVer'.
14 #>
15 
16 class AzureEngSemanticVersion : IComparable {
17   [int] $Major
18   [int] $Minor
19   [int] $Patch
20   [string] $PrereleaseLabelSeparator
21   [string] $PrereleaseLabel
22   [string] $PrereleaseNumberSeparator
23   [string] $BuildNumberSeparator
24   # BuildNumber is string to preserve zero-padding where applicable
25   [string] $BuildNumber
26   [int] $PrereleaseNumber
27   [bool] $IsPrerelease
28   [string] $VersionType
29   [string] $RawVersion
30   [bool] $IsSemVerFormat
31   [string] $DefaultPrereleaseLabel
32   [string] $DefaultAlphaReleaseLabel
33 
34   # Regex inspired but simplified from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
35   # Validation: https://regex101.com/r/vkijKf/426
36   static [string] $SEMVER_REGEX = "(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:(?<presep>-?)(?<prelabel>[a-zA-Z]+)(?:(?<prenumsep>\.?)(?<prenumber>[0-9]{1,8})(?:(?<buildnumsep>\.?)(?<buildnumber>\d{1,3}))?)?)?"
37 
38   static [AzureEngSemanticVersion] ParseVersionString([string] $versionString)
39   {
40     $version = [AzureEngSemanticVersion]::new($versionString)
41 
42     if (!$version.IsSemVerFormat) {
43       return $null
44     }
45     return $version
46   }
47 
48   static [AzureEngSemanticVersion] ParsePythonVersionString([string] $versionString)
49   {
50     $version = [AzureEngSemanticVersion]::ParseVersionString($versionString)
51 
52     if (!$version) {
53       return $null
54     }
55 
56     $version.SetupPythonConventions()
57     return $version
58   }
59 
60   AzureEngSemanticVersion([string] $versionString)
61   {
62     if ($versionString -match "^$([AzureEngSemanticVersion]::SEMVER_REGEX)$")
63     {
64       $this.IsSemVerFormat = $true
65       $this.RawVersion = $versionString
66       $this.Major = [int]$matches.Major
67       $this.Minor = [int]$matches.Minor
68       $this.Patch = [int]$matches.Patch
69 
70       # If Language exists and is set to python setup the python conventions.
71       $parseLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore")
72       if ($parseLanguage -eq "python") {
73         $this.SetupPythonConventions()
74       }
75       else {
76         $this.SetupDefaultConventions()
77       }
78 
79       if ($null -eq $matches['prelabel'])
80       {
81         # artifically provide these values for non-prereleases to enable easy sorting of them later than prereleases.
82         $this.PrereleaseLabel = "zzz"
83         $this.PrereleaseNumber = 99999999
84         $this.IsPrerelease = $false
85         $this.VersionType = "GA"
86         if ($this.Patch -ne 0) {
87           $this.VersionType = "Patch"
88         }
89       }
90       else
91       {
92         $this.PrereleaseLabel = $matches["prelabel"]
93         $this.PrereleaseLabelSeparator = $matches["presep"]
94         $this.PrereleaseNumber = [int]$matches["prenumber"]
95         $this.PrereleaseNumberSeparator = $matches["prenumsep"]
96         $this.IsPrerelease = $true
97         $this.VersionType = "Beta"
98 
99         $this.BuildNumberSeparator = $matches["buildnumsep"]
100         $this.BuildNumber = $matches["buildnumber"]
101       }
102     }
103     else
104     {
105       $this.RawVersion = $versionString
106       $this.IsSemVerFormat = $false
107     }
108   }
109 
110   # If a prerelease label exists, it must be 'beta', and similar semantics used in our release guidelines
111   # See https://azure.github.io/azure-sdk/policies_releases.html#package-versioning
112   [bool] HasValidPrereleaseLabel()
113   {
114     if ($this.IsPrerelease -eq $true) {
115       if ($this.PrereleaseLabel -ne $this.DefaultPrereleaseLabel -and $this.PrereleaseLabel -ne $this.DefaultAlphaReleaseLabel) {
116         Write-Host "Unexpected pre-release identifier '$($this.PrereleaseLabel)', "`
117                    "should be '$($this.DefaultPrereleaseLabel)' or '$($this.DefaultAlphaReleaseLabel)'"
118         return $false;
119       }
120       if ($this.PrereleaseNumber -lt 1)
121       {
122         Write-Host "Unexpected pre-release version '$($this.PrereleaseNumber)', should be >= '1'"
123         return $false;
124       }
125     }
126 
127     return $true;
128   }
129 
130   [string] ToString()
131   {
132     $versionString = "{0}.{1}.{2}" -F $this.Major, $this.Minor, $this.Patch
133 
134     if ($this.IsPrerelease)
135     {
136       $versionString += $this.PrereleaseLabelSeparator + $this.PrereleaseLabel + `
137                         $this.PrereleaseNumberSeparator + $this.PrereleaseNumber
138       if ($this.BuildNumber) {
139           $versionString += $this.BuildNumberSeparator + $this.BuildNumber
140       }
141     }
142     return $versionString;
143   }
144 
145   [void] IncrementAndSetToPrerelease() {
146     if ($this.IsPrerelease -eq $false)
147     {
148       $this.PrereleaseLabel = $this.DefaultPrereleaseLabel
149       $this.PrereleaseNumber = 1
150       $this.Minor++
151       $this.Patch = 0
152       $this.IsPrerelease = $true
153     }
154     else
155     {
156       if ($this.BuildNumber) {
157         throw "Cannot increment releases tagged with azure pipelines build numbers"
158       }
159       $this.PrereleaseNumber++
160     }
161   }
162 
163   [void] SetupPythonConventions()
164   {
165     # Python uses no separators and "b" for beta so this sets up the the object to work with those conventions
166     $this.PrereleaseLabelSeparator = $this.PrereleaseNumberSeparator = $this.BuildNumberSeparator = ""
167     $this.DefaultPrereleaseLabel = "b"
168     $this.DefaultAlphaReleaseLabel = "a"
169   }
170 
171   [void] SetupDefaultConventions()
172   {
173     # Use the default common conventions
174     $this.PrereleaseLabelSeparator = "-"
175     $this.PrereleaseNumberSeparator = "."
176     $this.BuildNumberSeparator = "."
177     $this.DefaultPrereleaseLabel = "beta"
178     $this.DefaultAlphaReleaseLabel = "alpha"
179   }
180 
181   [int] CompareTo($other)
182   {
183     if ($other -isnot [AzureEngSemanticVersion]) {
184       throw "Cannot compare $other with $this"
185     }
186 
187     $ret = $this.Major.CompareTo($other.Major)
188     if ($ret) { return $ret }
189 
190     $ret = $this.Minor.CompareTo($other.Minor)
191     if ($ret) { return $ret }
192 
193     $ret = $this.Patch.CompareTo($other.Patch)
194     if ($ret) { return $ret }
195 
196     # Mimic PowerShell that uses case-insensitive comparisons by default.
197     $ret = [string]::Compare($this.PrereleaseLabel, $other.PrereleaseLabel, $true)
198     if ($ret) { return $ret }
199 
200     $ret = $this.PrereleaseNumber.CompareTo($other.PrereleaseNumber)
201     if ($ret) { return $ret }
202 
203     return ([int] $this.BuildNumber).CompareTo([int] $other.BuildNumber)
204   }
205 
206   static [string[]] SortVersionStrings([string[]] $versionStrings)
207   {
208     $versions = $versionStrings | ForEach-Object { [AzureEngSemanticVersion]::ParseVersionString($_) }
209     $sortedVersions = [AzureEngSemanticVersion]::SortVersions($versions)
210     return ($sortedVersions | ForEach-Object { $_.RawVersion })
211   }
212 
213   static [AzureEngSemanticVersion[]] SortVersions([AzureEngSemanticVersion[]] $versions)
214   {
215     return $versions | Sort-Object -Descending
216   }
217 
218   static [void] QuickTests()
219   {
220     $global:Language = ""
221     $versions = @(
222       "1.0.1",
223       "2.0.0",
224       "2.0.0-alpha.20200920",
225       "2.0.0-alpha.20200920.1",
226       "2.0.0-beta.2",
227       "1.0.10",
228       "2.0.0-alpha.20201221.03",
229       "2.0.0-alpha.20201221.1",
230       "2.0.0-alpha.20201221.5",
231       "2.0.0-alpha.20201221.2",
232       "2.0.0-alpha.20201221.10",
233       "2.0.0-beta.1",
234       "2.0.0-beta.10",
235       "1.0.0",
236       "1.0.0b2",
237       "1.0.2")
238 
239     $expectedSort = @(
240       "2.0.0",
241       "2.0.0-beta.10",
242       "2.0.0-beta.2",
243       "2.0.0-beta.1",
244       "2.0.0-alpha.20201221.10",
245       "2.0.0-alpha.20201221.5",
246       "2.0.0-alpha.20201221.03",
247       "2.0.0-alpha.20201221.2",
248       "2.0.0-alpha.20201221.1",
249       "2.0.0-alpha.20200920.1",
250       "2.0.0-alpha.20200920",
251       "1.0.10",
252       "1.0.2",
253       "1.0.1",
254       "1.0.0",
255       "1.0.0b2")
256 
257     $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions)
258 
259     for ($i = 0; $i -lt $expectedSort.Count; $i++)
260     {
261       if ($sort[$i] -ne $expectedSort[$i]) {
262         Write-Host "Error: Incorrect version sort:"
263         Write-Host "Expected: "
264         Write-Host $expectedSort
265         Write-Host "Actual:"
266         Write-Host $sort
267         break
268       }
269     }
270 
271     $alphaVerString = "1.2.3-alpha.20200828.9"
272     $alphaVer = [AzureEngSemanticVersion]::new($alphaVerString)
273     if (!$alphaVer.IsPrerelease) {
274       Write-Host "Expected alpha version to be marked as prerelease"
275     }
276     if ($alphaVer.Major -ne 1 -or $alphaVer.Minor -ne 2 -or $alphaVer.Patch -ne 3 -or `
277         $alphaVer.PrereleaseLabel -ne "alpha" -or $alphaVer.PrereleaseNumber -ne 20200828 -or $alphaVer.BuildNumber -ne 9) {
278       Write-Host "Error: Didn't correctly parse alpha version string $alphaVerString"
279     }
280     if ($alphaVerString -ne $alphaVer.ToString()) {
281       Write-Host "Error: alpha string did not correctly round trip with ToString. Expected: $($alphaVerString), Actual: $($alphaVer)"
282     }
283 
284     $global:Language = "python"
285     $pythonAlphaVerString = "1.2.3a20200828009"
286     $pythonAlphaVer = [AzureEngSemanticVersion]::new($pythonAlphaVerString)
287     if (!$pythonAlphaVer.IsPrerelease) {
288       Write-Host "Expected python alpha version to be marked as prerelease"
289     }
290     # Note: For python we lump build number into prerelease number, since it simplifies the code and regex, and is behaviorally the same
291     if ($pythonAlphaVer.Major -ne 1 -or $pythonAlphaVer.Minor -ne 2 -or $pythonAlphaVer.Patch -ne 3 `
292         -or $pythonAlphaVer.PrereleaseLabel -ne "a" -or $pythonAlphaVer.PrereleaseNumber -ne 20200828 `
293         -or $pythonAlphaVer.BuildNumber -ne "009") {
294       Write-Host "Error: Didn't correctly parse python alpha version string $pythonAlphaVerString"
295     }
296     if ($pythonAlphaVerString -ne $pythonAlphaVer.ToString()) {
297       Write-Host "Error: python alpha string did not correctly round trip with ToString. Expected: $($pythonAlphaVerString), Actual: $($pythonAlphaVer)"
298     }
299 
300     $versions = @("1.0.1", "2.0.0", "2.0.0a20201208001", "2.0.0a20201105020", "2.0.0a20201208012", `
301                   "2.0.0b2", "1.0.10", "2.0.0b1", "2.0.0b10", "1.0.0", "1.0.0b2", "1.0.2")
302     $expectedSort = @("2.0.0", "2.0.0b10", "2.0.0b2", "2.0.0b1", "2.0.0a20201208012", "2.0.0a20201208001", `
303                       "2.0.0a20201105020", "1.0.10", "1.0.2", "1.0.1", "1.0.0", "1.0.0b2")
304     $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions)
305     for ($i = 0; $i -lt $expectedSort.Count; $i++)
306     {
307       if ($sort[$i] -ne $expectedSort[$i]) {
308         Write-Host "Error: Incorrect python version sort:"
309         Write-Host "Expected: "
310         Write-Host $expectedSort
311         Write-Host "Actual:"
312         Write-Host $sort
313         break
314       }
315     }
316 
317     $global:Language = ""
318 
319     $gaVerString = "1.2.3"
320     $gaVer = [AzureEngSemanticVersion]::ParseVersionString($gaVerString)
321     if ($gaVer.Major -ne 1 -or $gaVer.Minor -ne 2 -or $gaVer.Patch -ne 3) {
322       Write-Host "Error: Didn't correctly parse ga version string $gaVerString"
323     }
324     if ($gaVerString -ne $gaVer.ToString()) {
325       Write-Host "Error: Ga string did not correctly round trip with ToString. Expected: $($gaVerString), Actual: $($gaVer)"
326     }
327     $gaVer.IncrementAndSetToPrerelease()
328     if ("1.3.0-beta.1" -ne $gaVer.ToString()) {
329       Write-Host "Error: Ga string did not correctly increment"
330     }
331 
332     $betaVerString = "1.2.3-beta.4"
333     $betaVer = [AzureEngSemanticVersion]::ParseVersionString($betaVerString)
334     if ($betaVer.Major -ne 1 -or $betaVer.Minor -ne 2 -or $betaVer.Patch -ne 3 -or $betaVer.PrereleaseLabel -ne "beta" -or $betaVer.PrereleaseNumber -ne 4) {
335       Write-Host "Error: Didn't correctly parse beta version string $betaVerString"
336     }
337     if ($betaVerString -ne $betaVer.ToString()) {
338       Write-Host "Error: beta string did not correctly round trip with ToString. Expected: $($betaVerString), Actual: $($betaVer)"
339     }
340     $betaVer.IncrementAndSetToPrerelease()
341     if ("1.2.3-beta.5" -ne $betaVer.ToString()) {
342       Write-Host "Error: Beta string did not correctly increment"
343     }
344 
345     $pythonBetaVerString = "1.2.3b4"
346     $pbetaVer = [AzureEngSemanticVersion]::ParsePythonVersionString($pythonBetaVerString)
347     if ($pbetaVer.Major -ne 1 -or $pbetaVer.Minor -ne 2 -or $pbetaVer.Patch -ne 3 -or $pbetaVer.PrereleaseLabel -ne "b" -or $pbetaVer.PrereleaseNumber -ne 4) {
348       Write-Host "Error: Didn't correctly parse python beta string $pythonBetaVerString"
349     }
350     if ($pythonBetaVerString -ne $pbetaVer.ToString()) {
351       Write-Host "Error: python beta string did not correctly round trip with ToString"
352     }
353     $pbetaVer.IncrementAndSetToPrerelease()
354     if ("1.2.3b5" -ne $pbetaVer.ToString()) {
355       Write-Host "Error: Python beta string did not correctly increment"
356     }
357 
358     Write-Host "QuickTests done"
359   }
360 }
361