diff --git a/Actions/Github-Helper.psm1 b/Actions/Github-Helper.psm1 index 3c93fed5c..4bde6c3a4 100644 --- a/Actions/Github-Helper.psm1 +++ b/Actions/Github-Helper.psm1 @@ -826,6 +826,27 @@ function DownloadRelease { $assetPattern2 = "^$escapedProject-$escapedMask-.+\.zip$" Write-Host "AssetPatterns: '$assetPattern1' | '$assetPattern2'" $assets = @($release.assets | Where-Object { $_.name -match $assetPattern1 -or $_.name -match $assetPattern2 }) + $loosePattern = "^$escapedProject-.+-$escapedMask-.+\.zip$" + if ($assets.Count -eq 0 -and $project -ne '*') { + # No assets matched the strict patterns - the release might have been created from a branch containing a + # hyphen, in which case the branch segment spans multiple hyphen separated parts. Fall back to the loose pattern. + $assets = @($release.assets | Where-Object { $_.name -match $loosePattern }) + if ($assets) { + OutputWarning -message "No release assets matched project '$project' exactly, using $($assets.Count) asset(s) matching the '$project' name prefix instead: $($assets.name -join ', '). These assets were likely created from a branch containing a hyphen. If they belong to a different project with a similar name, this is not the expected behavior." + } + } + # Warn when other release assets share the same project name prefix but were excluded by strict matching. + # A loose pattern (using .+ instead of [^-]+) also matches assets whose names contain more than one segment + # between the project name and the mask - either because they belong to a different project sharing the same + # prefix (e.g. 'my-project-extra-library' when project is 'my-project') or because the release was + # created from a branch containing a hyphen. + if ($project -ne '*') { + $assetIds = @($assets | ForEach-Object { $_.id }) + $excludedPrefixAssets = @($release.assets | Where-Object { $_.name -match $loosePattern -and $_.id -notin $assetIds }) + if ($excludedPrefixAssets) { + OutputWarning -message "Found $($excludedPrefixAssets.Count) release asset(s) sharing the '$project' name prefix that were not included for project '$project': $($excludedPrefixAssets.name -join ', '). These assets either belong to a different project with a similar name or were created from a branch containing a hyphen (which isn't supported in release asset names). If this is unexpected, check for projects with similar names or hyphens in the branch name used for creating the release." + } + } foreach($asset in $assets) { $uri = "$api_url/repos/$repository/releases/assets/$($asset.id)" Write-Host $uri diff --git a/Tests/GitHub-Helper.Test.ps1 b/Tests/GitHub-Helper.Test.ps1 index 431969d16..38f807407 100644 --- a/Tests/GitHub-Helper.Test.ps1 +++ b/Tests/GitHub-Helper.Test.ps1 @@ -1,267 +1,445 @@ -Get-Module Github-Helper | Remove-Module -Force -Import-Module (Join-Path $PSScriptRoot '..\Actions\Github-Helper.psm1' -Resolve) - -Describe "GitHub-Helper Tests" { - BeforeAll { - . (Join-Path $PSScriptRoot '../Actions/AL-Go-Helper.ps1') - } - - It 'SemVerStrToSemVerObj/SemVerObjToSemVerStr' { - { SemVerStrToSemVerObj -semVerStr 'not semver' } | Should -Throw - { SemVerStrToSemVerObj -semVerStr '' } | Should -Throw - { SemVerStrToSemVerObj -semVerStr 'v1.2' } | Should -Throw - { SemVerStrToSemVerObj -semVerStr '1.2' } | Should -Throw - - SemVerStrToSemVerObj -semVerStr 'v1.2.3' | SemVerObjToSemVerStr | Should -Be 'v1.2.3' - SemVerStrToSemVerObj -semVerStr '1.2.3' | SemVerObjToSemVerStr | Should -Be '1.2.3' - SemVerStrToSemVerObj -semVerStr '1.2.0' | SemVerObjToSemVerStr | Should -Be '1.2.0' - SemVerStrToSemVerObj -semVerStr 'v1.2' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0' - SemVerStrToSemVerObj -semVerStr '1.2' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be '1.2.0' - SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.3-alpha.1' - SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.3.beta' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.3-alpha.1.2.3.beta' - SemVerStrToSemVerObj -semVerStr 'v1.2-beta' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0-beta' - SemVerStrToSemVerObj -semVerStr 'v1.2-beta-1' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0-beta-1' - - { SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.3.beta.5' } | Should -Throw - { SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.zzzz.beta.5' } | Should -Throw - - CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.0' | Should -Be 0 - CompareSemVerStrs -semVerStr1 'v3.2.1' -semVerStr2 '3.2.1' | Should -Be 0 - CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.0' | Should -Be 0 - CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.1' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.1.0' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '2.0.0' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '2.0.1' -semVerStr2 '2.0.0' | Should -Be 1 - CompareSemVerStrs -semVerStr1 '2.1.0' -semVerStr2 '2.0.0' | Should -Be 1 - CompareSemVerStrs -semVerStr1 '2.0.0' -semVerStr2 '20.0.0' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '2.10.0' -semVerStr2 '2.1.0' | Should -Be 1 - CompareSemVerStrs -semVerStr1 '2.10.2' -semVerStr2 '2.10.20' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '2.0.0' -semVerStr2 '2.0.0-alpha' | Should -Be 1 - CompareSemVerStrs -semVerStr1 '2.0.0-alpha' -semVerStr2 '2.0.0-beta' | Should -Be -1 - CompareSemVerStrs -semVerStr1 '1.2.3-alpha.1.2.3.beta' -semVerStr2 'v1.2.3-alpha.1.2.3.alpha' | Should -Be 1 - } - - It 'GetLatestRelease handles releases/26.x branch' { - # Mock GetReleases to return a list of releases (using -ModuleName to mock within the module) - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.x branch - should find the latest 26.x release (26.3.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' - $result.tag_name | Should -Be '26.3.0' - - # Test releases/26 branch - should find the latest 26.x release (26.3.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' - $result.tag_name | Should -Be '26.3.0' - - # Test releases/25 branch - should find the latest 25.x release (25.1.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25' - $result.tag_name | Should -Be '25.1.0' - } - - It 'GetLatestRelease handles releases/26.3 branch (major.minor)' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.3.5'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '26.3.4'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.3 branch - should find the latest 26.3.x release (26.3.5) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.3' - $result.tag_name | Should -Be '26.3.5' - } - - It 'GetLatestRelease handles main branch (non-release branch)' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - ) - } - - # Test main branch - should return the latest overall release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'main' - $result.tag_name | Should -Be '26.3.0' - } - - It 'GetLatestRelease returns null when no matching release found' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '24.0.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.x branch - no 26.x releases exist - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' - $result | Should -Be $null - } - - It 'GetLatestRelease handles release/26.x branch (singular form)' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - ) - } - - # Test release/26.x branch (singular form) - should also work - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'release/26.x' - $result.tag_name | Should -Be '26.3.0' - } - - It 'GetLatestRelease falls back to overall latest release for invalid version format' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - ) - } - - # Test with invalid version format - should fall back to overall latest release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/abc' - $result.tag_name | Should -Be '26.3.0' - - # Test with just ".x" - should fall back to overall latest release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/.x' - $result.tag_name | Should -Be '26.3.0' - } - - It 'GetLatestRelease ignores prerelease and draft releases when filtering by major version' { - # Mock GetReleases to return a list of releases including prereleases and drafts - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '26.5.0'; prerelease = $true; draft = $false } # prerelease - should be ignored - [PSCustomObject]@{ tag_name = '26.4.0'; prerelease = $false; draft = $true } # draft - should be ignored - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } # valid - should be selected - [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.x branch - should find the latest non-prerelease, non-draft 26.x release (26.3.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' - $result.tag_name | Should -Be '26.3.0' - - # Test releases/26 branch - should also find 26.3.0, ignoring prerelease and draft - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' - $result.tag_name | Should -Be '26.3.0' - } - - It 'GetLatestRelease handles v-prefixed tags correctly' { - # Mock GetReleases to return a list of releases with 'v' prefixed tags - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = 'v26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = 'v26.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = 'v25.1.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = 'v25.0.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.x branch - should find the latest v26.x release (v26.3.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' - $result.tag_name | Should -Be 'v26.3.0' - - # Test releases/26 branch - should find the latest v26.x release (v26.3.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' - $result.tag_name | Should -Be 'v26.3.0' - - # Test releases/25 branch - should find the latest v25.x release (v25.1.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25' - $result.tag_name | Should -Be 'v25.1.0' - } - - It 'GetLatestRelease handles mixed prefixed and non-prefixed tags' { - # Mock GetReleases to return a mix of 'v' prefixed and non-prefixed tags - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = 'v26.3.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = 'v25.1.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/26.x branch - should find the latest 26.x release (v26.3.0, which is first) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' - $result.tag_name | Should -Be 'v26.3.0' - - # Test releases/25.x branch - should find the latest 25.x release (v25.1.0) - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25.x' - $result.tag_name | Should -Be 'v25.1.0' - } - - It 'GetLatestRelease handles alternative branch naming formats' { - # Mock GetReleases to return a list of releases - Mock GetReleases -ModuleName Github-Helper { - return @( - [PSCustomObject]@{ tag_name = '27.2.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '27.1.0'; prerelease = $false; draft = $false } - [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } - ) - } - - # Test releases/27x branch (without dot) - should find the latest 27.x release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/27x' - $result.tag_name | Should -Be '27.2.0' - - # Test releases/v27 branch (with v prefix) - should find the latest 27.x release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v27' - $result.tag_name | Should -Be '27.2.0' - - # Test releases/v27.x branch (with v prefix and .x suffix) - should find the latest 27.x release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v27.x' - $result.tag_name | Should -Be '27.2.0' - - # Test releases/v26x branch (with v prefix and x suffix without dot) - should find the latest 26.x release - $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v26x' - $result.tag_name | Should -Be '26.3.0' - } - - It 'GetDependencies resolves .app files when the dependency folder path contains a glob metacharacter (])' { - # A branch name may contain ']' (git forbids '[' but allows ']'), which ends up in the - # downloaded dependency folder name. GetDependencies must enumerate that folder with - # -LiteralPath; using -Path would treat ']' as a wildcard and return the folder itself - # instead of the .app files inside it. - $saveToPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName - try { - $branch = 'bugs_Bug-638182--master]-Postserviceorder' - $depFolder = New-Item -ItemType Directory -Path (Join-Path $saveToPath "MyProj-$branch-Apps-PR1-20260709") - [System.IO.File]::WriteAllBytes((Join-Path $depFolder "App1.app"), [byte[]](1, 2, 3)) - [System.IO.File]::WriteAllBytes((Join-Path $depFolder "App2.app"), [byte[]](4, 5, 6)) - - $probingPath = [PSCustomObject]@{ - release_status = 'thisBuild' - buildMode = 'Default' - projects = 'MyProj' - branch = $branch - repo = 'https://github.com/test/repo' - } - - $result = @(GetDependencies -probingPathsJson $probingPath -saveToPath $saveToPath -masks @('Apps')) - - $result | Should -HaveCount 2 - $result | ForEach-Object { $_ | Should -BeLike '*.app' } - $result | Should -Contain (Join-Path $depFolder "App1.app") - $result | Should -Contain (Join-Path $depFolder "App2.app") - } - finally { - Remove-Item -Path $saveToPath -Recurse -Force -ErrorAction SilentlyContinue - } - } -} +Get-Module Github-Helper | Remove-Module -Force +Import-Module (Join-Path $PSScriptRoot '..\Actions\Github-Helper.psm1' -Resolve) + +Describe "GitHub-Helper Tests" { + BeforeAll { + . (Join-Path $PSScriptRoot '../Actions/AL-Go-Helper.ps1') + } + + It 'SemVerStrToSemVerObj/SemVerObjToSemVerStr' { + { SemVerStrToSemVerObj -semVerStr 'not semver' } | Should -Throw + { SemVerStrToSemVerObj -semVerStr '' } | Should -Throw + { SemVerStrToSemVerObj -semVerStr 'v1.2' } | Should -Throw + { SemVerStrToSemVerObj -semVerStr '1.2' } | Should -Throw + + SemVerStrToSemVerObj -semVerStr 'v1.2.3' | SemVerObjToSemVerStr | Should -Be 'v1.2.3' + SemVerStrToSemVerObj -semVerStr '1.2.3' | SemVerObjToSemVerStr | Should -Be '1.2.3' + SemVerStrToSemVerObj -semVerStr '1.2.0' | SemVerObjToSemVerStr | Should -Be '1.2.0' + SemVerStrToSemVerObj -semVerStr 'v1.2' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0' + SemVerStrToSemVerObj -semVerStr '1.2' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be '1.2.0' + SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.3-alpha.1' + SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.3.beta' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.3-alpha.1.2.3.beta' + SemVerStrToSemVerObj -semVerStr 'v1.2-beta' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0-beta' + SemVerStrToSemVerObj -semVerStr 'v1.2-beta-1' -allowMajorMinorOnly | SemVerObjToSemVerStr | Should -Be 'v1.2.0-beta-1' + + { SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.3.beta.5' } | Should -Throw + { SemVerStrToSemVerObj -semVerStr 'v1.2.3-alpha.1.2.zzzz.beta.5' } | Should -Throw + + CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.0' | Should -Be 0 + CompareSemVerStrs -semVerStr1 'v3.2.1' -semVerStr2 '3.2.1' | Should -Be 0 + CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.0' | Should -Be 0 + CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.0.1' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '1.1.0' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '1.0.0' -semVerStr2 '2.0.0' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '2.0.1' -semVerStr2 '2.0.0' | Should -Be 1 + CompareSemVerStrs -semVerStr1 '2.1.0' -semVerStr2 '2.0.0' | Should -Be 1 + CompareSemVerStrs -semVerStr1 '2.0.0' -semVerStr2 '20.0.0' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '2.10.0' -semVerStr2 '2.1.0' | Should -Be 1 + CompareSemVerStrs -semVerStr1 '2.10.2' -semVerStr2 '2.10.20' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '2.0.0' -semVerStr2 '2.0.0-alpha' | Should -Be 1 + CompareSemVerStrs -semVerStr1 '2.0.0-alpha' -semVerStr2 '2.0.0-beta' | Should -Be -1 + CompareSemVerStrs -semVerStr1 '1.2.3-alpha.1.2.3.beta' -semVerStr2 'v1.2.3-alpha.1.2.3.alpha' | Should -Be 1 + } + + It 'GetLatestRelease handles releases/26.x branch' { + # Mock GetReleases to return a list of releases (using -ModuleName to mock within the module) + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.x branch - should find the latest 26.x release (26.3.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' + $result.tag_name | Should -Be '26.3.0' + + # Test releases/26 branch - should find the latest 26.x release (26.3.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' + $result.tag_name | Should -Be '26.3.0' + + # Test releases/25 branch - should find the latest 25.x release (25.1.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25' + $result.tag_name | Should -Be '25.1.0' + } + + It 'GetLatestRelease handles releases/26.3 branch (major.minor)' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.3.5'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '26.3.4'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.3 branch - should find the latest 26.3.x release (26.3.5) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.3' + $result.tag_name | Should -Be '26.3.5' + } + + It 'GetLatestRelease handles main branch (non-release branch)' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + ) + } + + # Test main branch - should return the latest overall release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'main' + $result.tag_name | Should -Be '26.3.0' + } + + It 'GetLatestRelease returns null when no matching release found' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '24.0.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.x branch - no 26.x releases exist + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' + $result | Should -Be $null + } + + It 'GetLatestRelease handles release/26.x branch (singular form)' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + ) + } + + # Test release/26.x branch (singular form) - should also work + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'release/26.x' + $result.tag_name | Should -Be '26.3.0' + } + + It 'GetLatestRelease falls back to overall latest release for invalid version format' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + ) + } + + # Test with invalid version format - should fall back to overall latest release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/abc' + $result.tag_name | Should -Be '26.3.0' + + # Test with just ".x" - should fall back to overall latest release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/.x' + $result.tag_name | Should -Be '26.3.0' + } + + It 'GetLatestRelease ignores prerelease and draft releases when filtering by major version' { + # Mock GetReleases to return a list of releases including prereleases and drafts + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '26.5.0'; prerelease = $true; draft = $false } # prerelease - should be ignored + [PSCustomObject]@{ tag_name = '26.4.0'; prerelease = $false; draft = $true } # draft - should be ignored + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } # valid - should be selected + [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.1.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.x branch - should find the latest non-prerelease, non-draft 26.x release (26.3.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' + $result.tag_name | Should -Be '26.3.0' + + # Test releases/26 branch - should also find 26.3.0, ignoring prerelease and draft + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' + $result.tag_name | Should -Be '26.3.0' + } + + It 'GetLatestRelease handles v-prefixed tags correctly' { + # Mock GetReleases to return a list of releases with 'v' prefixed tags + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = 'v26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = 'v26.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = 'v25.1.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = 'v25.0.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.x branch - should find the latest v26.x release (v26.3.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' + $result.tag_name | Should -Be 'v26.3.0' + + # Test releases/26 branch - should find the latest v26.x release (v26.3.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26' + $result.tag_name | Should -Be 'v26.3.0' + + # Test releases/25 branch - should find the latest v25.x release (v25.1.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25' + $result.tag_name | Should -Be 'v25.1.0' + } + + It 'GetLatestRelease handles mixed prefixed and non-prefixed tags' { + # Mock GetReleases to return a mix of 'v' prefixed and non-prefixed tags + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = 'v26.3.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '26.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = 'v25.1.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '25.0.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/26.x branch - should find the latest 26.x release (v26.3.0, which is first) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/26.x' + $result.tag_name | Should -Be 'v26.3.0' + + # Test releases/25.x branch - should find the latest 25.x release (v25.1.0) + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/25.x' + $result.tag_name | Should -Be 'v25.1.0' + } + + It 'GetLatestRelease handles alternative branch naming formats' { + # Mock GetReleases to return a list of releases + Mock GetReleases -ModuleName Github-Helper { + return @( + [PSCustomObject]@{ tag_name = '27.2.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '27.1.0'; prerelease = $false; draft = $false } + [PSCustomObject]@{ tag_name = '26.3.0'; prerelease = $false; draft = $false } + ) + } + + # Test releases/27x branch (without dot) - should find the latest 27.x release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/27x' + $result.tag_name | Should -Be '27.2.0' + + # Test releases/v27 branch (with v prefix) - should find the latest 27.x release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v27' + $result.tag_name | Should -Be '27.2.0' + + # Test releases/v27.x branch (with v prefix and .x suffix) - should find the latest 27.x release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v27.x' + $result.tag_name | Should -Be '27.2.0' + + # Test releases/v26x branch (with v prefix and x suffix without dot) - should find the latest 26.x release + $result = GetLatestRelease -token 'dummy' -api_url 'https://api.github.com' -repository 'test/repo' -ref 'releases/v26x' + $result.tag_name | Should -Be '26.3.0' + } + + It 'GetDependencies resolves .app files when the dependency folder path contains a glob metacharacter (])' { + # A branch name may contain ']' (git forbids '[' but allows ']'), which ends up in the + # downloaded dependency folder name. GetDependencies must enumerate that folder with + # -LiteralPath; using -Path would treat ']' as a wildcard and return the folder itself + # instead of the .app files inside it. + $saveToPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + try { + $branch = 'bugs_Bug-638182--master]-Postserviceorder' + $depFolder = New-Item -ItemType Directory -Path (Join-Path $saveToPath "MyProj-$branch-Apps-PR1-20260709") + [System.IO.File]::WriteAllBytes((Join-Path $depFolder "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $depFolder "App2.app"), [byte[]](4, 5, 6)) + + $probingPath = [PSCustomObject]@{ + release_status = 'thisBuild' + buildMode = 'Default' + projects = 'MyProj' + branch = $branch + repo = 'https://github.com/test/repo' + } + + $result = @(GetDependencies -probingPathsJson $probingPath -saveToPath $saveToPath -masks @('Apps')) + + $result | Should -HaveCount 2 + $result | ForEach-Object { $_ | Should -BeLike '*.app' } + $result | Should -Contain (Join-Path $depFolder "App1.app") + $result | Should -Contain (Join-Path $depFolder "App2.app") + } + finally { + Remove-Item -Path $saveToPath -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'DownloadRelease Asset Pattern Matching Tests' { + BeforeAll { + Mock GetHeaders -ModuleName Github-Helper { return @{ 'Authorization' = 'token dummy' } } + Mock InvokeWebRequest -ModuleName Github-Helper { + param($Headers, $Uri, $OutFile) + if ($Headers -and $Uri) { } + if ($OutFile) { + # Create a minimal valid empty zip file so Expand-Archive doesn't fail if called + $emptyZip = [byte[]](0x50, 0x4B, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + [System.IO.File]::WriteAllBytes($OutFile, $emptyZip) + } + } + } + + It 'Downloads only exact project assets and excludes assets from projects with the same name prefix' { + # Regression test for: https://github.com/microsoft/AL-Go/issues/2234 + # Project 'my-project' should NOT match release assets of 'my-project-extra-library' + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'my-project-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 2; name = 'my-project-extra-library-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 3; name = 'other-project-main-Apps-1.0.64.0.zip' } + ) + } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $result = DownloadRelease -token 'dummy' -projects 'my-project' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + $result | Should -HaveCount 1 + $result | Should -Match ([regex]::Escape('my-project-main-Apps-1.0.64.0.zip')) + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Downloads assets for a project even when another project shares the same root name' { + # Downloads assets for 'my-project-extra-library' without also downloading 'my-project' assets + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'my-project-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 2; name = 'my-project-extra-library-main-Apps-1.0.64.0.zip' } + ) + } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $result = DownloadRelease -token 'dummy' -projects 'my-project-extra-library' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + $result | Should -HaveCount 1 + $result | Should -Match ([regex]::Escape('my-project-extra-library-main-Apps-1.0.64.0.zip')) + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Downloads assets for wildcard project (*) matching all projects' { + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'my-project-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 2; name = 'my-project-extra-library-main-Apps-1.0.64.0.zip' } + ) + } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $result = DownloadRelease -token 'dummy' -projects '*' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + $result | Should -HaveCount 2 + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Downloads assets with no-branch pattern (Pattern 2) for legacy releases' { + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'myproject-Apps-1.0.0.0.zip' } + [PSCustomObject]@{ id = 2; name = 'other-project-Apps-1.0.0.0.zip' } + ) + } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $result = DownloadRelease -token 'dummy' -projects 'myproject' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + $result | Should -HaveCount 1 + $result | Should -Match ([regex]::Escape('myproject-Apps-1.0.0.0.zip')) + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Falls back to loose matching when the release was created from a branch containing a hyphen' { + # 'my-branch' contains a hyphen, so no asset matches the strict pattern - the asset should still be downloaded + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'myproject-my-branch-Apps-1.0.0.0.zip' } + [PSCustomObject]@{ id = 2; name = 'other-project-my-branch-Apps-1.0.0.0.zip' } + ) + } + Mock OutputWarning -ModuleName Github-Helper { } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $result = DownloadRelease -token 'dummy' -projects 'myproject' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + $result | Should -HaveCount 1 + $result | Should -Match ([regex]::Escape('myproject-my-branch-Apps-1.0.0.0.zip')) + Assert-MockCalled OutputWarning -ModuleName Github-Helper -ParameterFilter { $message -like '*myproject-my-branch-Apps-1.0.0.0.zip*' } -Scope It + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Emits a warning when similarly-named project assets are present and excluded' { + # When release contains assets from 'my-project-extra-library' alongside 'my-project', + # downloading for project 'my-project' should emit a warning about the excluded assets. + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'my-project-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 2; name = 'my-project-extra-library-main-Apps-1.0.64.0.zip' } + ) + } + Mock OutputWarning -ModuleName Github-Helper { } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $null = DownloadRelease -token 'dummy' -projects 'my-project' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + Assert-MockCalled OutputWarning -ModuleName Github-Helper -ParameterFilter { $message -like '*my-project-extra-library*' } -Scope It + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Does not emit a warning when no similarly-named project assets are present' { + # When release only contains assets for the exact project, no warning should be emitted. + $mockRelease = @{ + Name = 'v1.0' + assets = @( + [PSCustomObject]@{ id = 1; name = 'my-project-main-Apps-1.0.64.0.zip' } + [PSCustomObject]@{ id = 2; name = 'other-project-main-Apps-1.0.64.0.zip' } + ) + } + Mock OutputWarning -ModuleName Github-Helper { } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tempPath | Out-Null + try { + $null = DownloadRelease -token 'dummy' -projects 'my-project' -api_url 'https://api.github.com' -repository 'test/repo' -path $tempPath -mask 'Apps' -release $mockRelease + + Assert-MockCalled OutputWarning -ModuleName Github-Helper -Scope It -Times 0 + } + finally { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } +}