diff --git a/eng/common-tests/ChangeLog-Operations.Tests.ps1 b/eng/common-tests/ChangeLog-Operations.Tests.ps1 index 2a406e80aff..6c76d9bd29c 100644 --- a/eng/common-tests/ChangeLog-Operations.Tests.ps1 +++ b/eng/common-tests/ChangeLog-Operations.Tests.ps1 @@ -225,3 +225,299 @@ Describe "Integration: Update Changelog Entry and Write Back" { $updatedEntries["0.9.0"] | Should -Not -BeNullOrEmpty } } + +Describe "Python post-release changelog parsing" { + BeforeEach { + $global:Language = "python" + } + + AfterEach { + $global:Language = "" + } + + It "Should parse changelog with GA post-release version '1.0.0.post1'" { + $changelogContent = @" +# Release History + +## 1.0.0.post1 (2025-03-01) + +### Other Changes + +- Updated package metadata for distribution + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial GA release +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $entries | Should -Not -BeNullOrEmpty + $entries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $entries["1.0.0.post1"].ReleaseVersion | Should -Be "1.0.0.post1" + $entries["1.0.0.post1"].ReleaseStatus | Should -Be "(2025-03-01)" + $entries["1.0.0.post1"].Sections.ContainsKey("Other Changes") | Should -BeTrue + $entries["1.0.0"] | Should -Not -BeNullOrEmpty + } + + It "Should parse changelog with beta post-release version '1.0.0b2.post1'" { + $changelogContent = @" +# Release History + +## 1.0.0b2.post1 (2025-03-01) + +### Other Changes + +- Updated classifier metadata for beta release + +## 1.0.0b2 (2025-02-15) + +### Features Added + +- Beta feature +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $entries | Should -Not -BeNullOrEmpty + $entries["1.0.0b2.post1"] | Should -Not -BeNullOrEmpty + $entries["1.0.0b2.post1"].ReleaseVersion | Should -Be "1.0.0b2.post1" + $entries["1.0.0b2.post1"].Sections.ContainsKey("Other Changes") | Should -BeTrue + $entries["1.0.0b2"] | Should -Not -BeNullOrEmpty + } + + It "Should parse changelog with alpha post-release version '2.0.0a20201208001.post2'" { + $changelogContent = @" +# Release History + +## 2.0.0a20201208001.post2 (2025-03-01) + +### Other Changes + +- Updated alpha package metadata + +## 2.0.0a20201208001 (2025-02-15) + +### Features Added + +- Alpha feature +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $entries | Should -Not -BeNullOrEmpty + $entries["2.0.0a20201208001.post2"] | Should -Not -BeNullOrEmpty + $entries["2.0.0a20201208001.post2"].ReleaseVersion | Should -Be "2.0.0a20201208001.post2" + $entries["2.0.0a20201208001"] | Should -Not -BeNullOrEmpty + } + + It "Should parse changelog with unreleased post-release version" { + $changelogContent = @" +# Release History + +## 1.0.0.post2 (Unreleased) + +### Other Changes + +## 1.0.0.post1 (2025-02-15) + +### Other Changes + +- Updated package metadata +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $entries | Should -Not -BeNullOrEmpty + $entries["1.0.0.post2"] | Should -Not -BeNullOrEmpty + $entries["1.0.0.post2"].ReleaseStatus | Should -Be "(Unreleased)" + $entries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + } + + It "Should parse changelog with multiple post-release versions" { + $changelogContent = @" +# Release History + +## 1.0.0.post3 (2025-04-01) + +### Other Changes + +- Updated package classifiers + +## 1.0.0.post2 (2025-03-15) + +### Other Changes + +- Updated package description + +## 1.0.0.post1 (2025-03-01) + +### Other Changes + +- Updated package metadata + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial release +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $entries | Should -Not -BeNullOrEmpty + $entries["1.0.0.post3"] | Should -Not -BeNullOrEmpty + $entries["1.0.0.post2"] | Should -Not -BeNullOrEmpty + $entries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $entries["1.0.0"] | Should -Not -BeNullOrEmpty + } +} + +Describe "Python post-release changelog sorting" { + BeforeEach { + $global:Language = "python" + } + + AfterEach { + $global:Language = "" + } + + It "Should sort post-release versions after their base version" { + $changelogContent = @" +# Release History + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial release + +## 1.0.0.post1 (2025-03-01) + +### Other Changes + +- Updated package metadata +"@ + $entries = Get-ChangeLogEntriesFromContent $changelogContent + $sorted = Sort-ChangeLogEntries -changeLogEntries $entries + + # post1 should come before (sort higher than) base 1.0.0 in descending sort + $sortedVersions = @($sorted | ForEach-Object { $_.ReleaseVersion }) + $postIndex = [array]::IndexOf($sortedVersions, "1.0.0.post1") + $baseIndex = [array]::IndexOf($sortedVersions, "1.0.0") + $postIndex | Should -BeLessThan $baseIndex + } +} + +Describe "Python post-release changelog integration" { + BeforeEach { + $global:Language = "python" + $script:tempChangelogPath = Join-Path ([System.IO.Path]::GetTempPath()) "CHANGELOG_$([System.Guid]::NewGuid().ToString()).md" + } + + AfterEach { + $global:Language = "" + if (Test-Path $script:tempChangelogPath) { + Remove-Item -Path $script:tempChangelogPath -Force -ErrorAction SilentlyContinue + } + } + + It "Should round-trip a changelog with post-release versions" { + $initialChangelog = @" +# Release History + +## 1.0.0.post1 (2025-03-01) + +### Other Changes + +- Updated package metadata + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial release +"@ + Set-Content -Path $script:tempChangelogPath -Value $initialChangelog + + $entries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $entries | Should -Not -BeNullOrEmpty + $entries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $entries["1.0.0"] | Should -Not -BeNullOrEmpty + + # Write back and re-read + Set-ChangeLogContent -ChangeLogLocation $script:tempChangelogPath -ChangeLogEntries $entries + + $reReadEntries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $reReadEntries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $reReadEntries["1.0.0.post1"].ReleaseVersion | Should -Be "1.0.0.post1" + $reReadEntries["1.0.0.post1"].ReleaseStatus | Should -Be "(2025-03-01)" + $reReadEntries["1.0.0"] | Should -Not -BeNullOrEmpty + } + + It "Should update a post-release changelog entry content" { + $initialChangelog = @" +# Release History + +## 1.0.0.post1 (Unreleased) + +### Other Changes + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial release +"@ + Set-Content -Path $script:tempChangelogPath -Value $initialChangelog + + $entries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $postEntry = $entries["1.0.0.post1"] + $postEntry | Should -Not -BeNullOrEmpty + + $newContent = "### Other Changes`n`n- Updated package metadata`n- Updated readme formatting" + Set-ChangeLogEntryContent -ChangeLogEntry $postEntry -NewContent $newContent -InitialAtxHeader $entries.InitialAtxHeader + Set-ChangeLogContent -ChangeLogLocation $script:tempChangelogPath -ChangeLogEntries $entries + + $updatedEntries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $updatedEntries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $updatedEntries["1.0.0.post1"].Sections.ContainsKey("Other Changes") | Should -BeTrue + + $updatedContent = Get-Content -Path $script:tempChangelogPath -Raw + $updatedContent | Should -Match "Updated package metadata" + $updatedContent | Should -Match "Updated readme formatting" + + # Verify the base version is preserved + $updatedEntries["1.0.0"] | Should -Not -BeNullOrEmpty + } + + It "Should preserve post-release entries when adding a new version" { + $initialChangelog = @" +# Release History + +## 1.0.0.post1 (2025-03-01) + +### Other Changes + +- Updated package metadata + +## 1.0.0 (2025-02-15) + +### Features Added + +- Initial release +"@ + Set-Content -Path $script:tempChangelogPath -Value $initialChangelog + + $entries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + + # Add a new version entry + $newEntry = [pscustomobject]@{ + ReleaseVersion = "1.1.0" + ReleaseStatus = "(Unreleased)" + ReleaseTitle = "## 1.1.0 (Unreleased)" + ReleaseContent = @("", "### Features Added", "", "- New feature for 1.1.0") + Sections = @{ "Features Added" = @("", "- New feature for 1.1.0") } + } + $entries["1.1.0"] = $newEntry + + Set-ChangeLogContent -ChangeLogLocation $script:tempChangelogPath -ChangeLogEntries $entries + + $updatedEntries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $updatedEntries["1.1.0"] | Should -Not -BeNullOrEmpty + $updatedEntries["1.0.0.post1"] | Should -Not -BeNullOrEmpty + $updatedEntries["1.0.0"] | Should -Not -BeNullOrEmpty + } +} diff --git a/eng/common-tests/SemVer.Tests.ps1 b/eng/common-tests/SemVer.Tests.ps1 new file mode 100644 index 00000000000..1044813e105 --- /dev/null +++ b/eng/common-tests/SemVer.Tests.ps1 @@ -0,0 +1,393 @@ +Import-Module Pester + +BeforeAll { + . $PSScriptRoot/../common/scripts/SemVer.ps1 +} + +Describe "Post-release version parsing - Default convention (negative tests for non-Python languages)" { + BeforeEach { + $global:Language = "" + } + + It "Should parse '1.0.0-post.1' as prerelease label 'post', NOT as post-release" { + $ver = [AzureEngSemanticVersion]::ParseVersionString("1.0.0-post.1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.PrereleaseLabel | Should -Be "post" + $ver.PrereleaseNumber | Should -Be 1 + $ver.IsPrerelease | Should -BeTrue + $ver.IsPostRelease | Should -BeFalse + } + + It "Should fail to parse '2.0.0-beta.1-post.1' (regex doesn't match)" { + $ver = [AzureEngSemanticVersion]::ParseVersionString("2.0.0-beta.1-post.1") + $ver | Should -BeNullOrEmpty + } + + It "Should fail to parse '1.2.3-alpha.20200828.9-post.3' (regex doesn't match)" { + $ver = [AzureEngSemanticVersion]::ParseVersionString("1.2.3-alpha.20200828.9-post.3") + $ver | Should -BeNullOrEmpty + } +} + +Describe "Post-release version parsing - Python convention" { + It "Should parse GA post-release '1.0.0.post1'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 1 + $ver.Minor | Should -Be 0 + $ver.Patch | Should -Be 0 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.PrereleaseLabel | Should -BeNullOrEmpty + $ver.IsPrerelease | Should -BeFalse + $ver.VersionType | Should -Be "GA" + } + + It "Should parse patch version post-release '1.2.3.post5'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.2.3.post5") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 1 + $ver.Minor | Should -Be 2 + $ver.Patch | Should -Be 3 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 5 + $ver.PrereleaseLabel | Should -BeNullOrEmpty + $ver.IsPrerelease | Should -BeFalse + $ver.VersionType | Should -Be "Patch" + } + + It "Should parse beta prerelease post-release '1.0.0b2.post1'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2.post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 1 + $ver.Minor | Should -Be 0 + $ver.Patch | Should -Be 0 + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.IsPrerelease | Should -BeTrue + $ver.VersionType | Should -Be "Beta" + } + + It "Should parse alpha prerelease post-release '2.0.0a20201208001.post2'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("2.0.0a20201208001.post2") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 2 + $ver.Minor | Should -Be 0 + $ver.Patch | Should -Be 0 + $ver.PrereleaseLabel | Should -Be "a" + $ver.PrereleaseNumber | Should -Be 20201208 + $ver.BuildNumber | Should -Be "001" + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 2 + $ver.IsPrerelease | Should -BeTrue + } + + It "Should parse zero-major post-release '0.1.0.post1'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("0.1.0.post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 0 + $ver.Minor | Should -Be 1 + $ver.Patch | Should -Be 0 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.IsPrerelease | Should -BeTrue + $ver.VersionType | Should -Be "Beta" + } + + It "Should parse implicit post-release number '1.0.0.post' as post0" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.Major | Should -Be 1 + $ver.Minor | Should -Be 0 + $ver.Patch | Should -Be 0 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.PrereleaseLabel | Should -BeNullOrEmpty + $ver.IsPrerelease | Should -BeFalse + $ver.VersionType | Should -Be "GA" + } + + It "Should parse implicit prerelease post-release '1.0.0b2.post' as post0" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2.post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsSemVerFormat | Should -BeTrue + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.IsPrerelease | Should -BeTrue + } +} + +Describe "Post-release version ToString round-trip - Default convention (non-Python languages don't support post-release, so should round-trip as prerelease)" { + BeforeEach { + $global:Language = "" + } + + It "'1.0.0-post.1' round-trips as prerelease (label 'post'), not post-release" { + $ver = [AzureEngSemanticVersion]::ParseVersionString("1.0.0-post.1") + $ver.ToString() | Should -Be "1.0.0-post.1" + $ver.IsPostRelease | Should -BeFalse + $ver.PrereleaseLabel | Should -Be "post" + } +} + +Describe "PEP 440 alternate post-release format normalization - Python convention" { + It "Should normalize hyphen separator '1.0.0-post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0-post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should normalize underscore separator '1.0.0_post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0_post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should normalize no-separator '1.0.0post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0post1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should normalize dot-number separator '1.0.0.post.1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post.1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should normalize uppercase '1.0.0.POST1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.POST1") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should normalize implicit post number '1.0.0.post' to '1.0.0.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.ToString() | Should -Be "1.0.0.post0" + } + + It "Should normalize implicit post number with hyphen '1.0.0-post' to '1.0.0.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0-post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.ToString() | Should -Be "1.0.0.post0" + } + + It "Should normalize implicit post number with underscore '1.0.0_post' to '1.0.0.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0_post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.ToString() | Should -Be "1.0.0.post0" + } + + It "Should normalize implicit post number with no separator '1.0.0post' to '1.0.0.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0post") + $ver | Should -Not -BeNullOrEmpty + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.ToString() | Should -Be "1.0.0.post0" + } + + It "Should normalize implicit prerelease post number '1.0.0b2.post' to '1.0.0b2.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2.post") + $ver | Should -Not -BeNullOrEmpty + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 0 + $ver.ToString() | Should -Be "1.0.0b2.post0" + } + + It "Should normalize prerelease hyphen separator '1.0.0b2-post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2-post1") + $ver | Should -Not -BeNullOrEmpty + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0b2.post1" + } + + It "Should normalize prerelease underscore separator '1.0.0b2_post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2_post1") + $ver | Should -Not -BeNullOrEmpty + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0b2.post1" + } + + It "Should normalize prerelease no-separator '1.0.0b2post1' to canonical form" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2post1") + $ver | Should -Not -BeNullOrEmpty + $ver.PrereleaseLabel | Should -Be "b" + $ver.PrereleaseNumber | Should -Be 2 + $ver.IsPostRelease | Should -BeTrue + $ver.PostReleaseNumber | Should -Be 1 + $ver.ToString() | Should -Be "1.0.0b2.post1" + } +} + +Describe "Post-release version ToString round-trip - Python convention" { + It "Should round-trip GA post-release '1.0.0.post1'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post1") + $ver.ToString() | Should -Be "1.0.0.post1" + } + + It "Should round-trip patch version post-release '1.2.3.post5'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.2.3.post5") + $ver.ToString() | Should -Be "1.2.3.post5" + } + + It "Should round-trip beta prerelease post-release '1.0.0b2.post1'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2.post1") + $ver.ToString() | Should -Be "1.0.0b2.post1" + } + + It "Should round-trip alpha prerelease post-release '2.0.0a20201208001.post2'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("2.0.0a20201208001.post2") + $ver.ToString() | Should -Be "2.0.0a20201208001.post2" + } + + It "Should normalize implicit post-release '1.0.0.post' to '1.0.0.post0'" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post") + $ver.ToString() | Should -Be "1.0.0.post0" + } +} + +Describe "Post-release version sorting - Python convention" { + BeforeAll { + $global:Language = "python" + } + + AfterAll { + $global:Language = "" + } + + It "Should sort GA post-releases after GA and before next patch" { + $versions = @( + "1.0.1", + "1.0.0.post2", + "1.0.0", + "1.0.0.post1" + ) + $expectedSort = @( + "1.0.1", + "1.0.0.post2", + "1.0.0.post1", + "1.0.0" + ) + $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions) + for ($i = 0; $i -lt $expectedSort.Count; $i++) { + $sort[$i] | Should -Be $expectedSort[$i] + } + } + + It "Should sort prerelease post-releases after prerelease and before next prerelease" { + $versions = @( + "1.0.0", + "1.0.0b2", + "1.0.0b1.post1", + "1.0.0b1" + ) + $expectedSort = @( + "1.0.0", + "1.0.0b2", + "1.0.0b1.post1", + "1.0.0b1" + ) + $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions) + for ($i = 0; $i -lt $expectedSort.Count; $i++) { + $sort[$i] | Should -Be $expectedSort[$i] + } + } + + It "Should sort mixed versions with post-releases correctly" { + $versions = @( + "2.0.0", + "1.0.0.post1", + "2.0.0b1", + "1.0.0", + "2.0.0b1.post1", + "2.0.0.post1", + "1.0.1" + ) + $expectedSort = @( + "2.0.0.post1", + "2.0.0", + "2.0.0b1.post1", + "2.0.0b1", + "1.0.1", + "1.0.0.post1", + "1.0.0" + ) + $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions) + for ($i = 0; $i -lt $expectedSort.Count; $i++) { + $sort[$i] | Should -Be $expectedSort[$i] + } + } + + It "Should sort implicit post-release (post0) equivalently to explicit post0" { + $versions = @( + "1.0.0.post1", + "1.0.0", + "1.0.0.post0" + ) + $expectedSort = @( + "1.0.0.post1", + "1.0.0.post0", + "1.0.0" + ) + $sort = [AzureEngSemanticVersion]::SortVersionStrings($versions) + for ($i = 0; $i -lt $expectedSort.Count; $i++) { + $sort[$i] | Should -Be $expectedSort[$i] + } + } +} + +Describe "Post-release version increment - Python convention" { + It "Should increment GA post-release '1.0.0.post1' to next prerelease" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post1") + $ver.IncrementAndSetToPrerelease() + $ver.ToString() | Should -Be "1.1.0b1" + } + + It "Should increment beta post-release '1.0.0b2.post1' to next prerelease number" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0b2.post1") + $ver.IncrementAndSetToPrerelease() + $ver.ToString() | Should -Be "1.0.0b3" + } + + It "Should increment zero-major post-release '0.1.0.post1' to next minor" { + $ver = [AzureEngSemanticVersion]::ParsePythonVersionString("0.1.0.post1") + $ver.IncrementAndSetToPrerelease() + $ver.ToString() | Should -Be "0.2.0" + } +} diff --git a/eng/common-tests/copy-docs-to-blobstorage.Tests.ps1 b/eng/common-tests/copy-docs-to-blobstorage.Tests.ps1 new file mode 100644 index 00000000000..d7f16ace2f3 --- /dev/null +++ b/eng/common-tests/copy-docs-to-blobstorage.Tests.ps1 @@ -0,0 +1,181 @@ +Import-Module Pester + +BeforeAll { + $ExitOnError = 0 + . $PSScriptRoot/../common/scripts/copy-docs-to-blobstorage.ps1 -DocLocation "/tmp" -ExitOnError 0 + # common.ps1 sets $Language = "Unknown" in this scope. Override it to "python" + # so AzureEngSemanticVersion uses the Python regex (with post-release support). + $Language = "python" +} + +Describe "ToSemVer - standard versions" { + It "Should parse GA version '1.0.0'" { + $v = ToSemVer "1.0.0" + $v | Should -Not -BeNullOrEmpty + $v.Major | Should -Be 1 + $v.Minor | Should -Be 0 + $v.Patch | Should -Be 0 + $v.IsPrerelease | Should -BeFalse + $v.IsPostRelease | Should -BeFalse + } + + It "Should parse prerelease version '1.0.0-beta.1'" { + $v = ToSemVer "1.0.0-beta.1" + $v | Should -Not -BeNullOrEmpty + $v.Major | Should -Be 1 + $v.PrereleaseLabel | Should -Be "beta" + $v.PrereleaseNumber | Should -Be 1 + $v.IsPrerelease | Should -BeTrue + $v.IsPostRelease | Should -BeFalse + } + + It "Should return null for invalid version" { + $v = ToSemVer "notaversion" + $v | Should -BeNullOrEmpty + } +} + +Describe "ToSemVer - Python post-release versions" { + It "Should parse Python beta version '1.0.0b2'" { + + $v = ToSemVer "1.0.0b2" + $v | Should -Not -BeNullOrEmpty + $v.PrereleaseLabel | Should -Be "b" + $v.PrereleaseNumber | Should -Be 2 + $v.IsPrerelease | Should -BeTrue + $v.IsPostRelease | Should -BeFalse + } + + It "Should parse GA post-release '1.0.0.post1' as non-prerelease" { + + $v = ToSemVer "1.0.0.post1" + $v | Should -Not -BeNullOrEmpty + $v.Major | Should -Be 1 + $v.Minor | Should -Be 0 + $v.Patch | Should -Be 0 + $v.IsPrerelease | Should -BeFalse + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 1 + } + + It "Should parse beta post-release '1.0.0b2.post1' as prerelease" { + + $v = ToSemVer "1.0.0b2.post1" + $v | Should -Not -BeNullOrEmpty + $v.PrereleaseLabel | Should -Be "b" + $v.PrereleaseNumber | Should -Be 2 + $v.IsPrerelease | Should -BeTrue + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 1 + } + + It "Should normalize hyphen-separated '1.0.0-post1' as post-release" { + + $v = ToSemVer "1.0.0-post1" + $v | Should -Not -BeNullOrEmpty + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 1 + $v.IsPrerelease | Should -BeFalse + } + + It "Should normalize underscore-separated '1.0.0_post1' as post-release" { + + $v = ToSemVer "1.0.0_post1" + $v | Should -Not -BeNullOrEmpty + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 1 + $v.IsPrerelease | Should -BeFalse + } + + It "Should normalize no-separator '1.0.0post1' as post-release" { + + $v = ToSemVer "1.0.0post1" + $v | Should -Not -BeNullOrEmpty + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 1 + $v.IsPrerelease | Should -BeFalse + } + + It "Should parse post-release with higher number '1.0.0.post15'" { + + $v = ToSemVer "1.0.0.post15" + $v | Should -Not -BeNullOrEmpty + $v.IsPostRelease | Should -BeTrue + $v.PostReleaseNumber | Should -Be 15 + } +} + +Describe "ToSemVer - non-Python '1.0.0-post1' is prerelease, not post-release" { + It "Should treat '-post' as a prerelease label for non-Python languages" { + $Language = "" + $v = ToSemVer "1.0.0-post1" + $v | Should -Not -BeNullOrEmpty + $v.PrereleaseLabel | Should -Be "post" + $v.IsPrerelease | Should -BeTrue + $v.IsPostRelease | Should -BeFalse + } +} + +Describe "Sort-Versions - Python post-release sorting" { + It "Should sort post-release after base GA version" { + + $sorted = Sort-Versions -VersionArray @("1.0.0", "1.0.0.post1") + $sorted.RawVersionsList[0] | Should -Be "1.0.0.post1" + $sorted.RawVersionsList[1] | Should -Be "1.0.0" + } + + It "Should sort multiple post-releases in descending order" { + + $sorted = Sort-Versions -VersionArray @("1.0.0", "1.0.0.post1", "1.0.0.post2") + $sorted.RawVersionsList[0] | Should -Be "1.0.0.post2" + $sorted.RawVersionsList[1] | Should -Be "1.0.0.post1" + $sorted.RawVersionsList[2] | Should -Be "1.0.0" + } + + It "Should sort post-release between base version and next version" { + + $sorted = Sort-Versions -VersionArray @("2.0.0", "1.0.0.post1", "1.0.0") + $sorted.RawVersionsList[0] | Should -Be "2.0.0" + $sorted.RawVersionsList[1] | Should -Be "1.0.0.post1" + $sorted.RawVersionsList[2] | Should -Be "1.0.0" + } + + It "Should sort mixed versions with post-releases and prereleases" { + + $sorted = Sort-Versions -VersionArray @("1.0.0", "1.0.0.post1", "1.0.0b1", "2.0.0") + $sorted.RawVersionsList[0] | Should -Be "2.0.0" + $sorted.RawVersionsList[1] | Should -Be "1.0.0.post1" + $sorted.RawVersionsList[2] | Should -Be "1.0.0" + $sorted.RawVersionsList[3] | Should -Be "1.0.0b1" + } +} + +Describe "Sort-Versions - Python post-release LatestGA/LatestPreview classification" { + It "Should classify GA post-release as LatestGA, not LatestPreview" { + + $sorted = Sort-Versions -VersionArray @("1.0.0", "1.0.0.post1") + $sorted.LatestGAPackage | Should -Be "1.0.0.post1" + $sorted.LatestPreviewPackage | Should -Be "" + } + + It "Should not set LatestPreview for GA post-release when newer GA exists" { + + $sorted = Sort-Versions -VersionArray @("2.0.0", "1.0.0.post1", "1.0.0") + $sorted.LatestGAPackage | Should -Be "2.0.0" + $sorted.LatestPreviewPackage | Should -Be "" + } + + It "Should set LatestPreview when prerelease is newer than GA post-release" { + + $sorted = Sort-Versions -VersionArray @("1.0.0", "1.0.0.post1", "2.0.0b1") + $sorted.LatestGAPackage | Should -Be "1.0.0.post1" + $sorted.LatestPreviewPackage | Should -Be "2.0.0b1" + } + + It "Should treat beta post-release as prerelease for LatestGA/LatestPreview" { + + $sorted = Sort-Versions -VersionArray @("1.0.0b2", "1.0.0b2.post1") + $sorted.LatestGAPackage | Should -Be "" + $sorted.LatestPreviewPackage | Should -Be "1.0.0b2.post1" + } +} diff --git a/eng/common/scripts/ChangeLog-Operations.ps1 b/eng/common/scripts/ChangeLog-Operations.ps1 index 170157ff511..98126a695c2 100644 --- a/eng/common/scripts/ChangeLog-Operations.ps1 +++ b/eng/common/scripts/ChangeLog-Operations.ps1 @@ -3,6 +3,7 @@ . "${PSScriptRoot}\SemVer.ps1" $RELEASE_TITLE_REGEX = "(?^\#+\s+(?$([AzureEngSemanticVersion]::SEMVER_REGEX))(\s+(?\(.+\))))" +$PYTHON_RELEASE_TITLE_REGEX = "(?^\#+\s+(?$([AzureEngSemanticVersion]::PYTHON_SEMVER_REGEX))(\s+(?\(.+\))))" $SECTION_HEADER_REGEX_SUFFIX = "##\s(?.*)" $CHANGELOG_UNRELEASED_STATUS = "(Unreleased)" $CHANGELOG_DATE_FORMAT = "yyyy-MM-dd" @@ -62,11 +63,13 @@ function Get-ChangeLogEntriesFromContent { $changeLogEntries | Add-Member -NotePropertyName "InitialAtxHeader" -NotePropertyValue $initialAtxHeader $releaseTitleAtxHeader = $initialAtxHeader + "#" $headerLines = @() + $parseLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore") + $titleRegex = if ($parseLanguage -eq "python") { $PYTHON_RELEASE_TITLE_REGEX } else { $RELEASE_TITLE_REGEX } try { # walk the document, finding where the version specifiers are and creating lists foreach ($line in $changeLogContent) { - if ($line -match $RELEASE_TITLE_REGEX) { + if ($line -match $titleRegex) { $changeLogEntry = [pscustomobject]@{ ReleaseVersion = $matches["version"] ReleaseStatus = $matches["releaseStatus"] diff --git a/eng/common/scripts/SemVer.ps1 b/eng/common/scripts/SemVer.ps1 index 689a70e778c..eeb7ab5f4ac 100644 --- a/eng/common/scripts/SemVer.ps1 +++ b/eng/common/scripts/SemVer.ps1 @@ -30,11 +30,20 @@ class AzureEngSemanticVersion : IComparable { [bool] $IsSemVerFormat [string] $DefaultPrereleaseLabel [string] $DefaultAlphaReleaseLabel + # For Python PEP440 post-release support only + [bool] $IsPostRelease + [int] $PostReleaseNumber + [string] $PostReleaseSeparator # Regex inspired but simplified from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string # Validation: https://regex101.com/r/vkijKf/426 static [string] $SEMVER_REGEX = "(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:(?-?)(?[a-zA-Z]+)(?:(?\.?)(?[0-9]{1,8})(?:(?\.?)(?\d{1,3}))?)?)?" + # Python PEP 440 post-release extension + # Handles all PEP 440 alternate formats: .postN, -postN, _postN, postN, .post.N, .post (implicit 0) (case-insensitive) + # Validation: https://regex101.com/r/rAdOg0/2 + static [string] $PYTHON_SEMVER_REGEX = [AzureEngSemanticVersion]::SEMVER_REGEX + "(?:(?[.\-_]?)(?(?i:post))\.?(?\d+)?)?" + static [AzureEngSemanticVersion] ParseVersionString([string] $versionString) { $version = [AzureEngSemanticVersion]::new($versionString) @@ -47,19 +56,34 @@ class AzureEngSemanticVersion : IComparable { static [AzureEngSemanticVersion] ParsePythonVersionString([string] $versionString) { - $version = [AzureEngSemanticVersion]::ParseVersionString($versionString) + $previousLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore") + $global:Language = "python" + $version = $null + try { + $version = [AzureEngSemanticVersion]::new($versionString) + } + finally { + $global:Language = $previousLanguage + } - if (!$version) { + if (!$version.IsSemVerFormat) { return $null } - - $version.SetupPythonConventions() return $version } AzureEngSemanticVersion([string] $versionString) { - if ($versionString -match "^$([AzureEngSemanticVersion]::SEMVER_REGEX)$") + $parseLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore") + + if ($parseLanguage -eq "python") { + $parseRegex = [AzureEngSemanticVersion]::PYTHON_SEMVER_REGEX + } + else { + $parseRegex = [AzureEngSemanticVersion]::SEMVER_REGEX + } + + if ($versionString -match "^${parseRegex}$") { $this.IsSemVerFormat = $true $this.RawVersion = $versionString @@ -67,16 +91,28 @@ class AzureEngSemanticVersion : IComparable { $this.Minor = [int]$matches.Minor $this.Patch = [int]$matches.Patch - # If Language exists and is set to python setup the python conventions. - $parseLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore") + $skipPrelabel = $false if ($parseLanguage -eq "python") { $this.SetupPythonConventions() + if ($matches['postword']) { + $this.IsPostRelease = $true + $this.PostReleaseNumber = if ($matches['postnum']) { [int]$matches['postnum'] } else { 0 } + $this.PostReleaseSeparator = ".post" + } + elseif ($matches['prelabel'] -and $matches['prelabel'] -ieq 'post') { + # Alternate PEP 440 forms like "1.0.0-post1" or "1.0.0post1" where the regex + # matched "post" as a prerelease label — reinterpret as post-release. + $this.IsPostRelease = $true + $this.PostReleaseNumber = [int]$matches['prenumber'] + $this.PostReleaseSeparator = ".post" + $skipPrelabel = $true + } } else { $this.SetupDefaultConventions() } - if ($null -eq $matches['prelabel']) + if ($skipPrelabel -or $null -eq $matches['prelabel']) { $this.IsPrerelease = $false $this.VersionType = "GA" @@ -141,6 +177,9 @@ class AzureEngSemanticVersion : IComparable { $versionString += $this.BuildNumberSeparator + $this.BuildNumber } } + if ($this.IsPostRelease) { + $versionString += $this.PostReleaseSeparator + $this.PostReleaseNumber + } return $versionString; } @@ -150,6 +189,13 @@ class AzureEngSemanticVersion : IComparable { throw "Cannot increment releases tagged with azure pipelines build numbers" } + # Clear post-release state before incrementing + if ($this.IsPostRelease) { + $this.IsPostRelease = $false + $this.PostReleaseNumber = 0 + $this.PostReleaseSeparator = "" + } + if ($this.PrereleaseLabel) { $this.PrereleaseNumber++ @@ -239,12 +285,27 @@ class AzureEngSemanticVersion : IComparable { $ret = $thisPrereleaseNumber.CompareTo($otherPrereleaseNumber) if ($ret) { return $ret } - return ([int] $this.BuildNumber).CompareTo([int] $other.BuildNumber) + $ret = ([int] $this.BuildNumber).CompareTo([int] $other.BuildNumber) + if ($ret) { return $ret } + + # Post-release versions sort after their base version + $thisPost = if ($this.IsPostRelease) { 1 } else { 0 } + $otherPost = if ($other.IsPostRelease) { 1 } else { 0 } + $ret = $thisPost.CompareTo($otherPost) + if ($ret) { return $ret } + + return $this.PostReleaseNumber.CompareTo($other.PostReleaseNumber) } static [string[]] SortVersionStrings([string[]] $versionStrings) { - $versions = $versionStrings | ForEach-Object { [AzureEngSemanticVersion]::ParseVersionString($_) } + $parseLanguage = (Get-Variable -Name "Language" -ValueOnly -ErrorAction "Ignore") + if ($parseLanguage -eq "python") { + $versions = $versionStrings | ForEach-Object { [AzureEngSemanticVersion]::ParsePythonVersionString($_) } + } + else { + $versions = $versionStrings | ForEach-Object { [AzureEngSemanticVersion]::ParseVersionString($_) } + } $sortedVersions = [AzureEngSemanticVersion]::SortVersions($versions) return ($sortedVersions | ForEach-Object { $_.RawVersion }) } @@ -429,6 +490,64 @@ class AzureEngSemanticVersion : IComparable { Write-Host "Error: version string did not correctly increment. Expected: $expected, Actual: $version" } + # Python post-release parsing tests + $postVerString = "1.0.0.post1" + $postVer = [AzureEngSemanticVersion]::ParsePythonVersionString($postVerString) + if ($postVer.Major -ne 1 -or $postVer.Minor -ne 0 -or $postVer.Patch -ne 0 -or ` + !$postVer.IsPostRelease -or $postVer.PostReleaseNumber -ne 1 -or $postVer.IsPrerelease) { + Write-Host "Error: Didn't correctly parse python post-release string $postVerString" + } + if ($postVerString -ne $postVer.ToString()) { + Write-Host "Error: post-release string did not correctly round trip with ToString. Expected: $($postVerString), Actual: $($postVer)" + } + + # Implicit post-release number (PEP 440: 1.0.0.post == 1.0.0.post0) + $implicitPostVerString = "1.0.0.post" + $implicitPostVer = [AzureEngSemanticVersion]::ParsePythonVersionString($implicitPostVerString) + if ($null -eq $implicitPostVer -or !$implicitPostVer.IsSemVerFormat) { + Write-Host "Error: Failed to parse implicit post-release string $implicitPostVerString" + } + elseif ($implicitPostVer.Major -ne 1 -or $implicitPostVer.Minor -ne 0 -or $implicitPostVer.Patch -ne 0 -or ` + !$implicitPostVer.IsPostRelease -or $implicitPostVer.PostReleaseNumber -ne 0) { + Write-Host "Error: Didn't correctly parse implicit post-release string $implicitPostVerString" + } + $expected = "1.0.0.post0" + if ($expected -ne $implicitPostVer.ToString()) { + Write-Host "Error: implicit post-release did not normalize. Expected: $expected, Actual: $($implicitPostVer)" + } + + # Prerelease + post-release + $preBetaPostString = "1.0.0b2.post1" + $preBetaPost = [AzureEngSemanticVersion]::ParsePythonVersionString($preBetaPostString) + if ($preBetaPost.Major -ne 1 -or $preBetaPost.Minor -ne 0 -or $preBetaPost.Patch -ne 0 -or ` + $preBetaPost.PrereleaseLabel -ne "b" -or $preBetaPost.PrereleaseNumber -ne 2 -or ` + !$preBetaPost.IsPostRelease -or $preBetaPost.PostReleaseNumber -ne 1) { + Write-Host "Error: Didn't correctly parse python prerelease post-release string $preBetaPostString" + } + if ($preBetaPostString -ne $preBetaPost.ToString()) { + Write-Host "Error: prerelease post-release string did not correctly round trip with ToString. Expected: $($preBetaPostString), Actual: $($preBetaPost)" + } + + # Post-release alternate separators normalize to canonical form + $expectedNormalized = "1.0.0.post1" + foreach ($altVerString in @("1.0.0-post1", "1.0.0_post1", "1.0.0post1")) { + $parsed = [AzureEngSemanticVersion]::ParsePythonVersionString($altVerString) + if ($null -eq $parsed -or !$parsed.IsPostRelease -or $parsed.PostReleaseNumber -ne 1) { + Write-Host "Error: Failed to parse alternate post-release format $altVerString" + } + if ($expectedNormalized -ne $parsed.ToString()) { + Write-Host "Error: Alternate post-release '$altVerString' did not normalize. Expected: $expectedNormalized, Actual: $($parsed)" + } + } + + # Post-release increment clears post state + $postIncVer = [AzureEngSemanticVersion]::ParsePythonVersionString("1.0.0.post1") + $postIncVer.IncrementAndSetToPrerelease() + $expected = "1.1.0b1" + if ($expected -ne $postIncVer.ToString()) { + Write-Host "Error: post-release increment did not produce expected result. Expected: $expected, Actual: $($postIncVer)" + } + Write-Host "QuickTests done" } } \ No newline at end of file diff --git a/eng/common/scripts/artifact-metadata-parsing.ps1 b/eng/common/scripts/artifact-metadata-parsing.ps1 index 9a45855c0ec..2bc1713e191 100644 --- a/eng/common/scripts/artifact-metadata-parsing.ps1 +++ b/eng/common/scripts/artifact-metadata-parsing.ps1 @@ -1,6 +1,6 @@ . (Join-Path $EngCommonScriptsDir SemVer.ps1) -$SDIST_PACKAGE_REGEX = "^(?.*)\-(?$([AzureEngSemanticVersion]::SEMVER_REGEX))" +$SDIST_PACKAGE_REGEX = "^(?.*)\-(?$([AzureEngSemanticVersion]::PYTHON_SEMVER_REGEX))" # Posts a github release for each item of the pkgList variable. Silently continue function CreateReleases($pkgList, $releaseApiUrl, $releaseSha) { diff --git a/eng/common/scripts/copy-docs-to-blobstorage.ps1 b/eng/common/scripts/copy-docs-to-blobstorage.ps1 index 852945338e9..aa33a93f33c 100644 --- a/eng/common/scripts/copy-docs-to-blobstorage.ps1 +++ b/eng/common/scripts/copy-docs-to-blobstorage.ps1 @@ -12,56 +12,28 @@ param ( . (Join-Path $PSScriptRoot common.ps1) -# Regex inspired but simplified from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -$SEMVER_REGEX = "^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-?(?[a-zA-Z-]*)(?:\.?(?0|[1-9]\d*))?)?$" - function ToSemVer($version){ - if ($version -match $SEMVER_REGEX) - { - if(-not $matches['prelabel']) { - # artifically provide these values for non-prereleases to enable easy sorting of them later than prereleases. - $prelabel = "zzz" - $prenumber = 999; - $isPre = $false; - } - else { - $prelabel = $matches["prelabel"] - $prenumber = 0 - - # some older packages don't have a prenumber, should handle this - if($matches["prenumber"]){ - $prenumber = [int]$matches["prenumber"] + try { + $sv = [AzureEngSemanticVersion]::new($version) + if (!$sv.IsSemVerFormat) { + if ($ExitOnError) { + throw "Unable to convert $version to valid semver and hard exit on error is enabled. Exiting." } - - $isPre = $true; - } - - New-Object PSObject -Property @{ - Major = [int]$matches['major'] - Minor = [int]$matches['minor'] - Patch = [int]$matches['patch'] - PrereleaseLabel = $prelabel - PrereleaseNumber = $prenumber - IsPrerelease = $isPre - RawVersion = $version + return $null } + return $sv } - else - { - if ($ExitOnError) - { + catch { + if ($ExitOnError) { throw "Unable to convert $version to valid semver and hard exit on error is enabled. Exiting." } - else - { - return $null - } + return $null } } function SortSemVersions($versions) { - return $versions | Sort-Object -Property Major, Minor, Patch, PrereleaseLabel, PrereleaseNumber -Descending + return $versions | Sort-Object -Descending } function Sort-Versions diff --git a/tools/spec-gen-sdk/src/utils/parseSemverVersionString.ts b/tools/spec-gen-sdk/src/utils/parseSemverVersionString.ts index 936b1ec5be7..d3608bdfeca 100644 --- a/tools/spec-gen-sdk/src/utils/parseSemverVersionString.ts +++ b/tools/spec-gen-sdk/src/utils/parseSemverVersionString.ts @@ -29,6 +29,13 @@ export function parseSemverVersionString( // Copied from https://github.com/Azure/azure-sdk-tools/blob/efa8a15c81e4614f2071b82dd8ca4f6ce6076f7b/eng/common/scripts/SemVer.ps1#L36 const SEMVER_REGEX = /(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:(?-?)(?[a-zA-Z]+)(?:(?\.?)(?[0-9]{1,8})(?:(?\.?)(?\d{1,3}))?)?)?/im; + // Python PEP 440 post-release extension: SEMVER_REGEX + optional post-release suffix. + // Handles all PEP 440 alternate formats: .postN, -postN, _postN, postN, .post.N, .post (implicit 0) (case-insensitive) + const PYTHON_SEMVER_REGEX = + /(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:(?-?)(?[a-zA-Z]+)(?:(?\.?)(?[0-9]{1,8})(?:(?\.?)(?\d{1,3}))?)?)?(?:(?[.\-_]?)(?[pP][oO][sS][tT])\.?(?\d+)?)?/im; + + const isPython = language.toLowerCase() === 'python'; + const parseRegex = isPython ? PYTHON_SEMVER_REGEX : SEMVER_REGEX; let prereleaseLabelSeparator: string | undefined; let prereleaseNumberSeparator: string | undefined; let buildNumberSeparator: string | undefined; @@ -45,7 +52,10 @@ export function parseSemverVersionString( let prelabel: string | undefined; let isSemVerFormat: boolean | undefined; let rawVersion: string | undefined; - const matches = versionString.match(SEMVER_REGEX); + let isPostRelease: boolean | undefined; + let postReleaseNumber: string | undefined; + let postReleaseSeparator: string | undefined; + const matches = versionString.match(parseRegex); if (matches) { isSemVerFormat = true; rawVersion = versionString; @@ -53,8 +63,7 @@ export function parseSemverVersionString( minor = matches && matches.groups && matches.groups.minor; patch = matches && matches.groups && matches.groups.patch; // If Language exists and is set to python setup the python conventions. - const parseLanguage = 'python'; - if (parseLanguage === language.toLowerCase()) { + if (isPython) { // Python uses no separators and 'b' for beta so this sets up the the object to work with those conventions prereleaseLabelSeparator = prereleaseNumberSeparator = buildNumberSeparator = ''; defaultPrereleaseLabel = 'b'; @@ -68,8 +77,28 @@ export function parseSemverVersionString( defaultAlphaReleaseLabel = 'alpha'; } + let skipPrelabel = false; + + // Python PEP 440 post-release detection + if (isPython) { + const postword = matches?.groups?.postword; + if (postword) { + // Case A: explicit post-release suffix (e.g., "1.0.0.post1", "1.0.0b2.post1") + isPostRelease = true; + postReleaseNumber = matches?.groups?.postnum ?? '0'; + postReleaseSeparator = '.post'; + } else if (matches?.groups?.prelabel && matches.groups.prelabel.toLowerCase() === 'post') { + // Case B: "post" captured as prelabel (e.g., "1.0.0-post1", "1.0.0post1") + // Reinterpret as post-release, not prerelease + isPostRelease = true; + postReleaseNumber = matches?.groups?.prenumber ?? '0'; + postReleaseSeparator = '.post'; + skipPrelabel = true; + } + } + prelabel = matches && matches.groups && matches.groups.prelabel; - if (!prelabel) { + if (skipPrelabel || !prelabel) { prereleaseLabel = 'zzz'; prereleaseNumber = '99999999'; isPrerelease = false; @@ -111,7 +140,10 @@ export function parseSemverVersionString( rawVersion, isSemVerFormat, defaultPrereleaseLabel, - defaultAlphaReleaseLabel + defaultAlphaReleaseLabel, + isPostRelease, + postReleaseNumber, + postReleaseSeparator }; } @@ -132,4 +164,7 @@ type ParseVersion = { prelabel: string | undefined; isSemVerFormat: boolean | undefined; rawVersion: string | undefined; + isPostRelease: boolean | undefined; + postReleaseNumber: string | undefined; + postReleaseSeparator: string | undefined; }; diff --git a/tools/spec-gen-sdk/test/utils/parseSemverVersionString.test.ts b/tools/spec-gen-sdk/test/utils/parseSemverVersionString.test.ts index 297e5517535..40dc48553cd 100644 --- a/tools/spec-gen-sdk/test/utils/parseSemverVersionString.test.ts +++ b/tools/spec-gen-sdk/test/utils/parseSemverVersionString.test.ts @@ -95,4 +95,53 @@ describe('parseSemverVersionString', () => { }), ).toBeTruthy(); }); + + it('Parse a standard Python post-release version', () => { + const parsedVersion = parseSemverVersionString('1.0.0.post1', 'Python'); + expect(parsedVersion?.isSemVerFormat).toEqual(true); + expect(parsedVersion?.major).toEqual('1'); + expect(parsedVersion?.minor).toEqual('0'); + expect(parsedVersion?.patch).toEqual('0'); + expect(parsedVersion?.isPostRelease).toEqual(true); + expect(parsedVersion?.postReleaseNumber).toEqual('1'); + expect(parsedVersion?.postReleaseSeparator).toEqual('.post'); + expect(parsedVersion?.isPrerelease).toEqual(false); + expect(parsedVersion?.versionType).toEqual('GA'); + }); + + it('Parse a Python post-release with implicit number', () => { + const parsedVersion = parseSemverVersionString('1.0.0.post', 'Python'); + expect(parsedVersion?.isPostRelease).toEqual(true); + expect(parsedVersion?.postReleaseNumber).toEqual('0'); + expect(parsedVersion?.isPrerelease).toEqual(false); + expect(parsedVersion?.versionType).toEqual('GA'); + }); + + it('Parse a Python prerelease + post-release version', () => { + const parsedVersion = parseSemverVersionString('1.0.0b2.post1', 'Python'); + expect(parsedVersion?.isPrerelease).toEqual(true); + expect(parsedVersion?.prereleaseLabel).toEqual('b'); + expect(parsedVersion?.prereleaseNumber).toEqual('2'); + expect(parsedVersion?.isPostRelease).toEqual(true); + expect(parsedVersion?.postReleaseNumber).toEqual('1'); + expect(parsedVersion?.versionType).toEqual('Beta'); + }); + + it('Parse Python post-release with alternate separators', () => { + for (const ver of ['1.0.0-post1', '1.0.0_post1', '1.0.0post1']) { + const parsedVersion = parseSemverVersionString(ver, 'Python'); + expect(parsedVersion?.isPostRelease).toEqual(true); + expect(parsedVersion?.postReleaseNumber).toEqual('1'); + expect(parsedVersion?.isPrerelease).toEqual(false); + expect(parsedVersion?.versionType).toEqual('GA'); + } + }); + + it('Non-Python language treats "post" as prerelease label, not post-release', () => { + const parsedVersion = parseSemverVersionString('1.0.0-post1', 'JavaScript'); + expect(parsedVersion?.isPostRelease).toBeUndefined(); + expect(parsedVersion?.isPrerelease).toEqual(true); + expect(parsedVersion?.versionType).toEqual('Beta'); + expect(parsedVersion?.prereleaseLabel).toEqual('post'); + }); });