From 23ff3838d32b2610f4477f39c355bf5f44ea83e9 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 11:23:57 +0100 Subject: [PATCH 01/29] Initial implementation --- .../DownloadProjectDependencies.Action.ps1 | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index be9f84fb75..87908c245e 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -9,6 +9,122 @@ [string] $token ) +<# + .SYNOPSIS + Downloads an app file from a URL to a specified download path. + .DESCRIPTION + Downloads an app file from a URL to a specified download path. + It handles URL decoding and sanitizes the file name. + .PARAMETER Url + The URL of the app file to download. + .PARAMETER DownloadPath + The path where the app file should be downloaded. + .OUTPUTS + The path to the downloaded app file. +#> +function Get-AppFileFromUrl { + Param( + [string] $Url, + [string] $DownloadPath + ) + # Get the file name from the URL + $urlWithoutQuery = $Url.Split('?')[0].TrimEnd('/') + $rawFileName = [System.IO.Path]::GetFileName($urlWithoutQuery) + $decodedFileName = [Uri]::UnescapeDataString($rawFileName) + $decodedFileName = [System.IO.Path]::GetFileName($decodedFileName) + + # Sanitize file name by removing invalid characters + $sanitizedFileName = $decodedFileName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "" + $sanitizedFileName = $sanitizedFileName.Trim() + + # Get the final app file path + $appFile = Join-Path $DownloadPath $sanitizedFileName + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile -MaximumRetryCount 3 -RetryIntervalSec 5 | Out-Null + return $appFile +} + +<# + .SYNOPSIS + Downloads dependencies from URLs specified in installApps and installTestApps settings. + .DESCRIPTION + Reads the installApps and installTestApps arrays from the repository settings. + For each entry that is a URL (starts with http:// or https://): + - Resolves any secret placeholders in the format ${{ secretName }} by looking up the secret value + - Downloads the app file to the specified destination path + For entries that are not URLs (local paths), they are returned as-is. + .PARAMETER DestinationPath + The path where the app files should be downloaded. + .OUTPUTS + A hashtable with Apps and TestApps arrays containing the resolved local file paths. +#> +function DownloadDependenciesFromInstallApps { + Param( + [string] $DestinationPath + ) + + $settings = $env:Settings | ConvertFrom-Json | ConvertTo-HashTable + + # Check if the installApps and installTestApps settings are empty + if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { + return @{ + "Apps" = @() + "TestApps" = @() + } + } + + # ENV:Secrets is not set when running Pull_Request trigger + if ($env:Secrets) { + $secrets = $env:Secrets | ConvertFrom-Json | ConvertTo-HashTable + } + else { + $secrets = @{} + } + + $install = @{ + "Apps" = @($settings.installApps) + "TestApps" = @($settings.installTestApps) + } + + + # Check if the installApps and installTestApps settings are empty + if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { + Write-Host "No installApps or installTestApps settings found." + return $install + } + + # Replace secret names in install.apps and install.testApps and download files from URLs + foreach($list in @('Apps','TestApps')) { + $install."$list" = @($install."$list" | ForEach-Object { + $appFile = $_ + + # If the app file is not a URL, return it as is + if ($appFile -notlike 'http*://*') { + Write-Host "install$($list) contains a local path: $appFile" + return $appFile + } + + # Else, check for secrets in the URL and replace them + $appFileUrl = $appFile + $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' + if ($appFile -match $pattern) { + $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$($matches[2])"))) + } + + # Download the app file to a temporary location + try { + Write-Host "Downloading app from URL: $appFile" + $appFile = Get-AppFileFromUrl -Url $appFileUrl -DownloadPath $DestinationPath + } catch { + throw "Setting: install$($list) contains an inaccessible URL: $($_). Error was: $($_.Exception.Message)" + } + + return $appFile + }) + } + + return $install +} + function DownloadDependenciesFromProbingPaths { param( $baseFolder, @@ -123,6 +239,10 @@ Write-Host "::group::Downloading project dependencies from probing paths" $downloadedDependencies += DownloadDependenciesFromProbingPaths -baseFolder $baseFolder -project $project -destinationPath $destinationPath -token $token Write-Host "::endgroup::" +Write-Host "::group::Downloading dependencies from settings (installApps and installTestApps)" +$settingsDependencies = DownloadDependenciesFromInstallApps -DestinationPath $destinationPath +Write-Host "::endgroup::" + $downloadedApps = @() $downloadedTestApps = @() @@ -137,6 +257,10 @@ $downloadedDependencies | ForEach-Object { } } +# Add dependencies from settings +$downloadedApps += $settingsDependencies.Apps +$downloadedTestApps += $settingsDependencies.TestApps + OutputMessageAndArray -message "Downloaded dependencies (Apps)" -arrayOfStrings $downloadedApps OutputMessageAndArray -message "Downloaded dependencies (Test Apps)" -arrayOfStrings $downloadedTestApps From abd86aa11eef011322cacbb48ee3d3537ce31b18 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 11:25:59 +0100 Subject: [PATCH 02/29] Add PS5 compatibility --- .../DownloadProjectDependencies.Action.ps1 | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 87908c245e..2bc02ffc84 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -39,7 +39,29 @@ function Get-AppFileFromUrl { # Get the final app file path $appFile = Join-Path $DownloadPath $sanitizedFileName - Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile -MaximumRetryCount 3 -RetryIntervalSec 5 | Out-Null + + # Download with retry logic (compatible with PS5 and PS7) + $maxRetries = 3 + $retryIntervalSec = 5 + $retryCount = 0 + $success = $false + while (-not $success -and $retryCount -lt $maxRetries) { + try { + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile | Out-Null + $success = $true + } + catch { + $retryCount++ + if ($retryCount -lt $maxRetries) { + Write-Host "Download failed, retrying in $retryIntervalSec seconds... (Attempt $retryCount of $maxRetries)" + Start-Sleep -Seconds $retryIntervalSec + } + else { + throw $_ + } + } + } + return $appFile } From a0b3ecd11b318f23da028fd7eea55cbc28ed689c Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 11:33:21 +0100 Subject: [PATCH 03/29] Remove duplicate check --- .../DownloadProjectDependencies.Action.ps1 | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 2bc02ffc84..2c35c600a2 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -86,14 +86,6 @@ function DownloadDependenciesFromInstallApps { $settings = $env:Settings | ConvertFrom-Json | ConvertTo-HashTable - # Check if the installApps and installTestApps settings are empty - if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { - return @{ - "Apps" = @() - "TestApps" = @() - } - } - # ENV:Secrets is not set when running Pull_Request trigger if ($env:Secrets) { $secrets = $env:Secrets | ConvertFrom-Json | ConvertTo-HashTable @@ -107,7 +99,6 @@ function DownloadDependenciesFromInstallApps { "TestApps" = @($settings.installTestApps) } - # Check if the installApps and installTestApps settings are empty if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { Write-Host "No installApps or installTestApps settings found." From 2a2e1cf234c4312ab2b48253369d36ace39f5fa3 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 11:35:52 +0100 Subject: [PATCH 04/29] Remove logic from Runpipeline --- Actions/RunPipeline/RunPipeline.ps1 | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index fcde6d99f7..3ae1c1e060 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -193,8 +193,8 @@ try { } $install = @{ - "Apps" = $settings.installApps - "TestApps" = $settings.installTestApps + "Apps" = @() + "TestApps" = @() } if ($installAppsJson -and (Test-Path $installAppsJson)) { @@ -220,30 +220,6 @@ try { $install.TestApps = $install.TestApps | ForEach-Object { $_.TrimStart("(").TrimEnd(")") } } - # Replace secret names in install.apps and install.testApps - foreach($list in @('Apps','TestApps')) { - $install."$list" = @($install."$list" | ForEach-Object { - $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' - $url = $_ - if ($url -match $pattern) { - $finalUrl = $url.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$($matches[2])"))) - } - else { - $finalUrl = $url - } - # Check validity of URL - if ($finalUrl -like 'http*://*') { - try { - Invoke-WebRequest -Method Head -UseBasicParsing -Uri $finalUrl | Out-Null - } - catch { - throw "Setting: install$($list) contains an inaccessible URL: $($url). Error was: $($_.Exception.Message)" - } - } - return $finalUrl - }) - } - # Analyze app.json version dependencies before launching pipeline # Analyze InstallApps and InstallTestApps before launching pipeline From 323d90a914dff887a1b5c563537b87ab4c730f39 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 11:45:01 +0100 Subject: [PATCH 05/29] releasenotes --- RELEASENOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bda0dd4a16..9af6862096 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,6 +11,10 @@ - AL-Go repositories with large amounts of projects may run into issues with too large environment variables - Discussion 1855 Add trigger 'workflow_call' to workflow 'Update AL-Go System Files' for reusability +### Download dependencies from installApps and installTestApps settings + +The DownloadProjectDependencies action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings. Previously, URL validation was done at build time without downloading. Now the files are downloaded upfront, improving error detection and build reliability. + ### Set default values for workflow inputs The `workflowDefaultInputs` setting now also applies to `workflow_call` inputs when an input with the same name exists for `workflow_dispatch`. From e992e7f8e67459b5b760f83c98c072d034ace742 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 13:12:51 +0100 Subject: [PATCH 06/29] Check secret exists --- .../DownloadProjectDependencies.Action.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 2c35c600a2..da40ca01a5 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -120,7 +120,11 @@ function DownloadDependenciesFromInstallApps { $appFileUrl = $appFile $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' if ($appFile -match $pattern) { - $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$($matches[2])"))) + $secretName = $matches[2] + if (-not $secrets.ContainsKey($secretName)) { + throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" + } + $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) } # Download the app file to a temporary location From 37a1b20a95ab819d24c75746aa296ae70f4f8cbc Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 13:16:10 +0100 Subject: [PATCH 07/29] handle empty sanitizedFileName --- .../DownloadProjectDependencies.Action.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index da40ca01a5..2a71d80ce8 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -37,6 +37,10 @@ function Get-AppFileFromUrl { $sanitizedFileName = $decodedFileName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "" $sanitizedFileName = $sanitizedFileName.Trim() + if ([string]::IsNullOrWhiteSpace($sanitizedFileName)) { + $sanitizedFileName = "$([Guid]::NewGuid().ToString()).app" + } + # Get the final app file path $appFile = Join-Path $DownloadPath $sanitizedFileName From 840a6128f3107aa7dfdc5d333854f20870afd348 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 13:30:51 +0100 Subject: [PATCH 08/29] Handle duplicate files --- .../DownloadProjectDependencies.Action.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 2a71d80ce8..9fe768831e 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -43,6 +43,9 @@ function Get-AppFileFromUrl { # Get the final app file path $appFile = Join-Path $DownloadPath $sanitizedFileName + if (Test-Path -LiteralPath $appFile) { + OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." + } # Download with retry logic (compatible with PS5 and PS7) $maxRetries = 3 From 3bbd0d1af57f795f205985933bbdc525b1e7fe25 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 13:35:09 +0100 Subject: [PATCH 09/29] Error message --- .../DownloadProjectDependencies.Action.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 9fe768831e..ac79fe6b86 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -139,7 +139,7 @@ function DownloadDependenciesFromInstallApps { Write-Host "Downloading app from URL: $appFile" $appFile = Get-AppFileFromUrl -Url $appFileUrl -DownloadPath $DestinationPath } catch { - throw "Setting: install$($list) contains an inaccessible URL: $($_). Error was: $($_.Exception.Message)" + throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" } return $appFile From b0d5fa482100156c3a5faa5bb20fab08cb25a92f Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 14:47:06 +0100 Subject: [PATCH 10/29] releasenotes --- RELEASENOTES.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9af6862096..0c2eb51504 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,9 +11,12 @@ - AL-Go repositories with large amounts of projects may run into issues with too large environment variables - Discussion 1855 Add trigger 'workflow_call' to workflow 'Update AL-Go System Files' for reusability -### Download dependencies from installApps and installTestApps settings +### Improving error detection and build reliability when downloading project dependencies -The DownloadProjectDependencies action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings. Previously, URL validation was done at build time without downloading. Now the files are downloaded upfront, improving error detection and build reliability. +The `DownloadProjectDependencies` action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings upfront, rather than validating URLs at build time. This change provides: +- Earlier detection of inaccessible or misconfigured URLs +- Clearer error messages when secrets are missing or URLs are invalid +- Warnings for potential issues like duplicate filenames or insecure HTTP connections ### Set default values for workflow inputs From 15554dc49aea5e9737d424d65a8b2c5e31ee0860 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 28 Jan 2026 14:49:27 +0100 Subject: [PATCH 11/29] Use Invoke-CommandWithRetry --- .../DownloadProjectDependencies.Action.ps1 | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index ac79fe6b86..73002fe9ef 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -47,27 +47,10 @@ function Get-AppFileFromUrl { OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." } - # Download with retry logic (compatible with PS5 and PS7) - $maxRetries = 3 - $retryIntervalSec = 5 - $retryCount = 0 - $success = $false - while (-not $success -and $retryCount -lt $maxRetries) { - try { - Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile | Out-Null - $success = $true - } - catch { - $retryCount++ - if ($retryCount -lt $maxRetries) { - Write-Host "Download failed, retrying in $retryIntervalSec seconds... (Attempt $retryCount of $maxRetries)" - Start-Sleep -Seconds $retryIntervalSec - } - else { - throw $_ - } - } - } + # Download with retry logic + Invoke-CommandWithRetry -ScriptBlock { + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile | Out-Null + } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 return $appFile } From c74fa442dbefd2d0ccbfb7b4101225cbb518a18c Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Tue, 3 Feb 2026 18:42:29 +0100 Subject: [PATCH 12/29] Fix for how install apps is set --- Actions/RunPipeline/RunPipeline.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index 3ae1c1e060..12b4b2b491 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -199,7 +199,7 @@ try { if ($installAppsJson -and (Test-Path $installAppsJson)) { try { - $install.Apps += @(Get-Content -Path $installAppsJson -Raw | ConvertFrom-Json) + $install.Apps = Get-Content -Path $installAppsJson | ConvertFrom-Json } catch { throw "Failed to parse JSON file at path '$installAppsJson'. Error: $($_.Exception.Message)" @@ -208,7 +208,7 @@ try { if ($installTestAppsJson -and (Test-Path $installTestAppsJson)) { try { - $install.TestApps += @(Get-Content -Path $installTestAppsJson -Raw | ConvertFrom-Json) + $install.TestApps = Get-Content -Path $installTestAppsJson | ConvertFrom-Json } catch { throw "Failed to parse JSON file at path '$installTestAppsJson'. Error: $($_.Exception.Message)" From 1c2a9d303328e68a9a9e51d1f9700c619626f467 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Tue, 3 Feb 2026 20:39:26 +0100 Subject: [PATCH 13/29] Handling for .zip files --- .../DownloadProjectDependencies.Action.ps1 | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 73002fe9ef..27629ebe94 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -11,18 +11,19 @@ <# .SYNOPSIS - Downloads an app file from a URL to a specified download path. + Downloads a file from a URL to a specified download path. .DESCRIPTION - Downloads an app file from a URL to a specified download path. + Downloads a file from a URL to a specified download path. It handles URL decoding and sanitizes the file name. + If the downloaded file is a zip file, it extracts the .app files from it. .PARAMETER Url - The URL of the app file to download. + The URL of the file to download. .PARAMETER DownloadPath - The path where the app file should be downloaded. + The path where the file should be downloaded. .OUTPUTS - The path to the downloaded app file. + An array of paths to the downloaded/extracted .app files. #> -function Get-AppFileFromUrl { +function Get-AppFilesFromUrl { Param( [string] $Url, [string] $DownloadPath @@ -41,18 +42,46 @@ function Get-AppFileFromUrl { $sanitizedFileName = "$([Guid]::NewGuid().ToString()).app" } - # Get the final app file path - $appFile = Join-Path $DownloadPath $sanitizedFileName - if (Test-Path -LiteralPath $appFile) { + # Get the final file path + $downloadedFile = Join-Path $DownloadPath $sanitizedFileName + if (Test-Path -LiteralPath $downloadedFile) { OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." } # Download with retry logic Invoke-CommandWithRetry -ScriptBlock { - Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $appFile | Out-Null + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 - return $appFile + # Check if the downloaded file is a zip file + $extension = [System.IO.Path]::GetExtension($downloadedFile).ToLowerInvariant() + if ($extension -eq '.zip') { + Write-Host "Extracting .app files from zip archive: $sanitizedFileName" + + # Extract to runner temp folder + $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($sanitizedFileName)) + Expand-Archive -Path $downloadedFile -DestinationPath $extractPath -Force + Remove-Item -Path $downloadedFile -Force + + # Find all .app files in the extracted folder and copy them to the download path + $appFiles = @() + foreach ($appFile in @(Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { + $destFile = Join-Path $DownloadPath $appFile.Name + Copy-Item -Path $appFile.FullName -Destination $destFile -Force + $appFiles += $destFile + } + + # Clean up the extracted folder + Remove-Item -Path $extractPath -Recurse -Force + + if ($appFiles.Count -eq 0) { + throw "Zip archive '$sanitizedFileName' does not contain any .app files" + } + Write-Host "Found $($appFiles.Count) .app file(s) in zip archive" + return $appFiles + } + + return @($downloadedFile) } <# @@ -117,15 +146,15 @@ function DownloadDependenciesFromInstallApps { $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) } - # Download the app file to a temporary location + # Download the file (may return multiple .app files if it's a zip) try { - Write-Host "Downloading app from URL: $appFile" - $appFile = Get-AppFileFromUrl -Url $appFileUrl -DownloadPath $DestinationPath + Write-Host "Downloading from URL: $appFile" + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath } catch { throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" } - return $appFile + return $appFiles }) } From 7c2cc34c40cc6efca9c9621cbc25ee974d46d502 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 08:48:40 +0100 Subject: [PATCH 14/29] Add tests --- .../DownloadProjectDependencies.Action.ps1 | 154 +---------- .../DownloadProjectDependencies.psm1 | 154 +++++++++++ Tests/DownloadProjectDependencies.Test.ps1 | 253 ++++++++++++++++++ Tests/runtests.ps1 | 4 +- 4 files changed, 411 insertions(+), 154 deletions(-) create mode 100644 Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 create mode 100644 Tests/DownloadProjectDependencies.Test.ps1 diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 27629ebe94..564727d58b 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -9,157 +9,7 @@ [string] $token ) -<# - .SYNOPSIS - Downloads a file from a URL to a specified download path. - .DESCRIPTION - Downloads a file from a URL to a specified download path. - It handles URL decoding and sanitizes the file name. - If the downloaded file is a zip file, it extracts the .app files from it. - .PARAMETER Url - The URL of the file to download. - .PARAMETER DownloadPath - The path where the file should be downloaded. - .OUTPUTS - An array of paths to the downloaded/extracted .app files. -#> -function Get-AppFilesFromUrl { - Param( - [string] $Url, - [string] $DownloadPath - ) - # Get the file name from the URL - $urlWithoutQuery = $Url.Split('?')[0].TrimEnd('/') - $rawFileName = [System.IO.Path]::GetFileName($urlWithoutQuery) - $decodedFileName = [Uri]::UnescapeDataString($rawFileName) - $decodedFileName = [System.IO.Path]::GetFileName($decodedFileName) - - # Sanitize file name by removing invalid characters - $sanitizedFileName = $decodedFileName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "" - $sanitizedFileName = $sanitizedFileName.Trim() - - if ([string]::IsNullOrWhiteSpace($sanitizedFileName)) { - $sanitizedFileName = "$([Guid]::NewGuid().ToString()).app" - } - - # Get the final file path - $downloadedFile = Join-Path $DownloadPath $sanitizedFileName - if (Test-Path -LiteralPath $downloadedFile) { - OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." - } - - # Download with retry logic - Invoke-CommandWithRetry -ScriptBlock { - Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null - } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 - - # Check if the downloaded file is a zip file - $extension = [System.IO.Path]::GetExtension($downloadedFile).ToLowerInvariant() - if ($extension -eq '.zip') { - Write-Host "Extracting .app files from zip archive: $sanitizedFileName" - - # Extract to runner temp folder - $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($sanitizedFileName)) - Expand-Archive -Path $downloadedFile -DestinationPath $extractPath -Force - Remove-Item -Path $downloadedFile -Force - - # Find all .app files in the extracted folder and copy them to the download path - $appFiles = @() - foreach ($appFile in @(Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { - $destFile = Join-Path $DownloadPath $appFile.Name - Copy-Item -Path $appFile.FullName -Destination $destFile -Force - $appFiles += $destFile - } - - # Clean up the extracted folder - Remove-Item -Path $extractPath -Recurse -Force - - if ($appFiles.Count -eq 0) { - throw "Zip archive '$sanitizedFileName' does not contain any .app files" - } - Write-Host "Found $($appFiles.Count) .app file(s) in zip archive" - return $appFiles - } - - return @($downloadedFile) -} - -<# - .SYNOPSIS - Downloads dependencies from URLs specified in installApps and installTestApps settings. - .DESCRIPTION - Reads the installApps and installTestApps arrays from the repository settings. - For each entry that is a URL (starts with http:// or https://): - - Resolves any secret placeholders in the format ${{ secretName }} by looking up the secret value - - Downloads the app file to the specified destination path - For entries that are not URLs (local paths), they are returned as-is. - .PARAMETER DestinationPath - The path where the app files should be downloaded. - .OUTPUTS - A hashtable with Apps and TestApps arrays containing the resolved local file paths. -#> -function DownloadDependenciesFromInstallApps { - Param( - [string] $DestinationPath - ) - - $settings = $env:Settings | ConvertFrom-Json | ConvertTo-HashTable - - # ENV:Secrets is not set when running Pull_Request trigger - if ($env:Secrets) { - $secrets = $env:Secrets | ConvertFrom-Json | ConvertTo-HashTable - } - else { - $secrets = @{} - } - - $install = @{ - "Apps" = @($settings.installApps) - "TestApps" = @($settings.installTestApps) - } - - # Check if the installApps and installTestApps settings are empty - if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { - Write-Host "No installApps or installTestApps settings found." - return $install - } - - # Replace secret names in install.apps and install.testApps and download files from URLs - foreach($list in @('Apps','TestApps')) { - $install."$list" = @($install."$list" | ForEach-Object { - $appFile = $_ - - # If the app file is not a URL, return it as is - if ($appFile -notlike 'http*://*') { - Write-Host "install$($list) contains a local path: $appFile" - return $appFile - } - - # Else, check for secrets in the URL and replace them - $appFileUrl = $appFile - $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' - if ($appFile -match $pattern) { - $secretName = $matches[2] - if (-not $secrets.ContainsKey($secretName)) { - throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" - } - $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) - } - - # Download the file (may return multiple .app files if it's a zip) - try { - Write-Host "Downloading from URL: $appFile" - $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath - } catch { - throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" - } - - return $appFiles - }) - } - - return $install -} +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "DownloadProjectDependencies.psm1" -Resolve) -DisableNameChecking function DownloadDependenciesFromProbingPaths { param( @@ -276,7 +126,7 @@ $downloadedDependencies += DownloadDependenciesFromProbingPaths -baseFolder $bas Write-Host "::endgroup::" Write-Host "::group::Downloading dependencies from settings (installApps and installTestApps)" -$settingsDependencies = DownloadDependenciesFromInstallApps -DestinationPath $destinationPath +$settingsDependencies = Get-DependenciesFromInstallApps -DestinationPath $destinationPath Write-Host "::endgroup::" $downloadedApps = @() diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 new file mode 100644 index 0000000000..dad5fe354d --- /dev/null +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -0,0 +1,154 @@ +<# + .SYNOPSIS + Downloads a file from a URL to a specified download path. + .DESCRIPTION + Downloads a file from a URL to a specified download path. + It handles URL decoding and sanitizes the file name. + If the downloaded file is a zip file, it extracts the .app files from it. + .PARAMETER Url + The URL of the file to download. + .PARAMETER DownloadPath + The path where the file should be downloaded. + .OUTPUTS + An array of paths to the downloaded/extracted .app files. +#> +function Get-AppFilesFromUrl { + Param( + [string] $Url, + [string] $DownloadPath + ) + # Get the file name from the URL + $urlWithoutQuery = $Url.Split('?')[0].TrimEnd('/') + $rawFileName = [System.IO.Path]::GetFileName($urlWithoutQuery) + $decodedFileName = [Uri]::UnescapeDataString($rawFileName) + $decodedFileName = [System.IO.Path]::GetFileName($decodedFileName) + + # Sanitize file name by removing invalid characters + $sanitizedFileName = $decodedFileName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "" + $sanitizedFileName = $sanitizedFileName.Trim() + + if ([string]::IsNullOrWhiteSpace($sanitizedFileName)) { + # Assume the file is an .app file if no valid name could be determined + $sanitizedFileName = "$([Guid]::NewGuid().ToString()).app" + } + + # Get the final file path + $downloadedFile = Join-Path $DownloadPath $sanitizedFileName + if (Test-Path -LiteralPath $downloadedFile) { + OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." + } + + # Download with retry logic + Invoke-CommandWithRetry -ScriptBlock { + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null + } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 + + # Check if the downloaded file is a zip file + $extension = [System.IO.Path]::GetExtension($downloadedFile).ToLowerInvariant() + if ($extension -eq '.zip') { + Write-Host "Extracting .app files from zip archive: $sanitizedFileName" + + # Extract to runner temp folder + $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($sanitizedFileName)) + Expand-Archive -Path $downloadedFile -DestinationPath $extractPath -Force + Remove-Item -Path $downloadedFile -Force + + # Find all .app files in the extracted folder and copy them to the download path + $appFiles = @() + foreach ($appFile in (Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { + $destFile = Join-Path $DownloadPath $appFile.Name + Copy-Item -Path $appFile.FullName -Destination $destFile -Force + $appFiles += $destFile + } + + # Clean up the extracted folder + Remove-Item -Path $extractPath -Recurse -Force + + if ($appFiles.Count -eq 0) { + throw "Zip archive '$sanitizedFileName' does not contain any .app files" + } + Write-Host "Found $($appFiles.Count) .app file(s) in zip archive" + return $appFiles + } + + return @($downloadedFile) +} + +<# + .SYNOPSIS + Downloads dependencies from URLs specified in installApps and installTestApps settings. + .DESCRIPTION + Reads the installApps and installTestApps arrays from the repository settings. + For each entry that is a URL (starts with http:// or https://): + - Resolves any secret placeholders in the format ${{ secretName }} by looking up the secret value + - Downloads the app file to the specified destination path + For entries that are not URLs (local paths), they are returned as-is. + .PARAMETER DestinationPath + The path where the app files should be downloaded. + .OUTPUTS + A hashtable with Apps and TestApps arrays containing the resolved local file paths. +#> +function Get-DependenciesFromInstallApps { + Param( + [string] $DestinationPath + ) + + $settings = $env:Settings | ConvertFrom-Json | ConvertTo-HashTable + + # ENV:Secrets is not set when running Pull_Request trigger + if ($env:Secrets) { + $secrets = $env:Secrets | ConvertFrom-Json | ConvertTo-HashTable + } + else { + $secrets = @{} + } + + $install = @{ + "Apps" = @($settings.installApps) + "TestApps" = @($settings.installTestApps) + } + + # Check if the installApps and installTestApps settings are empty + if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { + Write-Host "No installApps or installTestApps settings found." + return $install + } + + # Replace secret names in install.apps and install.testApps and download files from URLs + foreach($list in @('Apps','TestApps')) { + $install."$list" = @($install."$list" | ForEach-Object { + $appFile = $_ + + # If the app file is not a URL, return it as is + if ($appFile -notlike 'http*://*') { + Write-Host "install$($list) contains a local path: $appFile" + return $appFile + } + + # Else, check for secrets in the URL and replace them + $appFileUrl = $appFile + $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' + if ($appFile -match $pattern) { + $secretName = $matches[2] + if (-not $secrets.ContainsKey($secretName)) { + throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" + } + $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) + } + + # Download the file (may return multiple .app files if it's a zip) + try { + Write-Host "Downloading from URL: $appFile" + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath + } catch { + throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" + } + + return $appFiles + }) + } + + return $install +} + +Export-ModuleMember -Function Get-AppFilesFromUrl, Get-DependenciesFromInstallApps diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 new file mode 100644 index 0000000000..2c6c67e512 --- /dev/null +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -0,0 +1,253 @@ +Get-Module TestActionsHelper | Remove-Module -Force +Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1') +$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 + +# Import AL-Go-Helper first (needed for helper functions) +. (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + +# Import the module +Import-Module (Join-Path $PSScriptRoot "../Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1" -Resolve) -Force + +Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { + BeforeEach { + # Create a temp download folder + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'downloadPath', Justification = 'False positive.')] + $downloadPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Create a test .app file to use as mock response + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'mockAppFile', Justification = 'False positive.')] + $mockAppFile = Join-Path $env:RUNNER_TEMP "MockApp.app" + [System.IO.File]::WriteAllBytes($mockAppFile, [byte[]](1, 2, 3, 4, 5)) + } + + AfterEach { + # Clean up + if (Test-Path $downloadPath) { + Remove-Item -Path $downloadPath -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + } + + It 'Downloads a single .app file from URL' { + # Mock Invoke-WebRequest at module level - this works because Invoke-CommandWithRetry + # calls Invoke-WebRequest with a scriptblock that runs in the module scope + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*TestApp.app" + Test-Path @($result)[0] | Should -BeTrue + } + + It 'Extracts .app files from a zip archive' { + # Create test .app files in a zip + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSource" + New-Item -ItemType Directory -Path $zipSourcePath | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "App2.app"), [byte[]](4, 5, 6)) + $zipPath = Join-Path $env:RUNNER_TEMP "TestApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "App1.app") + $result | Should -Contain (Join-Path $downloadPath "App2.app") + Test-Path (Join-Path $downloadPath "App1.app") | Should -BeTrue + Test-Path (Join-Path $downloadPath "App2.app") | Should -BeTrue + # Zip file should be removed + Test-Path (Join-Path $downloadPath "TestApps.zip") | Should -BeFalse + } + + It 'Extracts .app files from nested folders in zip archive' { + # Create test .app files in nested structure + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNested" + New-Item -ItemType Directory -Path "$zipSourcePath\folder1\subfolder" -Force | Out-Null + New-Item -ItemType Directory -Path "$zipSourcePath\folder2" -Force | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "folder1\subfolder\NestedApp.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "folder2\AnotherApp.app"), [byte[]](4, 5, 6)) + $zipPath = Join-Path $env:RUNNER_TEMP "NestedApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NestedApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "NestedApp.app") + $result | Should -Contain (Join-Path $downloadPath "AnotherApp.app") + } + + It 'Throws error when zip contains no .app files' { + # Create zip with non-.app files + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNoApps" + New-Item -ItemType Directory -Path $zipSourcePath | Out-Null + Set-Content -Path (Join-Path $zipSourcePath "readme.txt") -Value "No apps here" + $zipPath = Join-Path $env:RUNNER_TEMP "NoApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + { Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath } | Should -Throw "*does not contain any .app files*" + } + + It 'Handles URL with query parameters' { + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*TestApp.app" + } + + It 'Generates GUID filename when URL path contains only invalid characters' { + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + # URL with only spaces/invalid chars as filename (after sanitization becomes empty) + $result = Get-AppFilesFromUrl -Url "https://example.com/%20%20%20" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -Match "\.app$" + # Should be a GUID pattern like: 12345678-1234-1234-1234-123456789abc.app + @($result)[0] | Should -Match "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.app$" + Test-Path @($result)[0] | Should -BeTrue + } +} + +Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { + BeforeEach { + # Create a temp download folder + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'downloadPath', Justification = 'False positive.')] + $downloadPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Store original env vars + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalSettings', Justification = 'False positive.')] + $originalSettings = $env:Settings + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalSecrets', Justification = 'False positive.')] + $originalSecrets = $env:Secrets + } + + AfterEach { + # Clean up + if (Test-Path $downloadPath) { + Remove-Item -Path $downloadPath -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + $env:Settings = $originalSettings + $env:Secrets = $originalSecrets + } + + It 'Returns empty arrays when no installApps or installTestApps configured' { + $env:Settings = @{ + installApps = @() + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -HaveCount 0 + $result.TestApps | Should -HaveCount 0 + } + + It 'Returns local paths unchanged' { + $env:Settings = @{ + installApps = @("C:\Apps\MyApp.app", ".\relative\path\App.app") + installTestApps = @("C:\TestApps\TestApp.app") + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -Contain "C:\Apps\MyApp.app" + $result.Apps | Should -Contain ".\relative\path\App.app" + $result.TestApps | Should -Contain "C:\TestApps\TestApp.app" + } + + It 'Downloads apps from URLs' { + $env:Settings = @{ + installApps = @("https://example.com/App1.app") + installTestApps = @("https://example.com/TestApp1.app") + } | ConvertTo-Json -Depth 10 + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3)) + } -ModuleName DownloadProjectDependencies + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -HaveCount 1 + $result.TestApps | Should -HaveCount 1 + } + + It 'Replaces secret placeholders in URLs' { + $env:Settings = @{ + installApps = @('https://example.com/App.app?token=${{ mySecret }}') + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + # Base64 encode the secret value + $secretValue = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("secret-token-value")) + $env:Secrets = @{ + mySecret = $secretValue + } | ConvertTo-Json -Depth 10 + + $script:capturedUrl = $null + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + $script:capturedUrl = $Uri + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3)) + } -ModuleName DownloadProjectDependencies + + $null = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $script:capturedUrl | Should -Be "https://example.com/App.app?token=secret-token-value" + } + + It 'Throws error for unknown secret reference' { + $env:Settings = @{ + installApps = @('https://example.com/App.app?token=${{ unknownSecret }}') + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + $env:Secrets = @{} | ConvertTo-Json -Depth 10 + + { Get-DependenciesFromInstallApps -DestinationPath $downloadPath } | Should -Throw "*unknown secret 'unknownSecret'*" + } +} diff --git a/Tests/runtests.ps1 b/Tests/runtests.ps1 index 28c292a111..a158f36ce7 100644 --- a/Tests/runtests.ps1 +++ b/Tests/runtests.ps1 @@ -20,10 +20,10 @@ try { $result = Invoke-Pester @(Get-ChildItem -Path (Join-Path $Path "*.Test.ps1")) -passthru if ($result.FailedCount -gt 0) { Write-Host "::Error::$($result.FailedCount) tests are failing" - $host.SetShouldExit(1) + #$host.SetShouldExit(1) } } catch { Write-Host "::Error::Error when running tests. The Error was $($_.Exception.Message)" - $host.SetShouldExit(1) + #$host.SetShouldExit(1) } From 0b446c02b1eed5a561d847a8612873db5d96d823 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 08:55:02 +0100 Subject: [PATCH 15/29] Cleanup --- Tests/runtests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/runtests.ps1 b/Tests/runtests.ps1 index a158f36ce7..28c292a111 100644 --- a/Tests/runtests.ps1 +++ b/Tests/runtests.ps1 @@ -20,10 +20,10 @@ try { $result = Invoke-Pester @(Get-ChildItem -Path (Join-Path $Path "*.Test.ps1")) -passthru if ($result.FailedCount -gt 0) { Write-Host "::Error::$($result.FailedCount) tests are failing" - #$host.SetShouldExit(1) + $host.SetShouldExit(1) } } catch { Write-Host "::Error::Error when running tests. The Error was $($_.Exception.Message)" - #$host.SetShouldExit(1) + $host.SetShouldExit(1) } From 50b19d589a2a0965648a239e8ee586fd5e1e0734 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 08:57:48 +0100 Subject: [PATCH 16/29] Import Github-Helper.psm1 --- .../DownloadProjectDependencies.psm1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index dad5fe354d..ac03173a5b 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -1,3 +1,5 @@ +Import-Module -Name (Join-Path $PSScriptRoot '../Github-Helper.psm1') + <# .SYNOPSIS Downloads a file from a URL to a specified download path. From cc992adee55e2e0bf2d34f1f8cb859718137262d Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 11:15:01 +0100 Subject: [PATCH 17/29] Add handling of local paths --- .../DownloadProjectDependencies.psm1 | 211 ++++++++++++++---- Tests/DownloadProjectDependencies.Test.ps1 | 151 ++++++++++++- 2 files changed, 305 insertions(+), 57 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index ac03173a5b..81147b3ee6 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -1,5 +1,133 @@ Import-Module -Name (Join-Path $PSScriptRoot '../Github-Helper.psm1') +<# + .SYNOPSIS + Tests if a file is a ZIP archive by checking for the "PK" magic bytes. + .PARAMETER Path + The path to the file to test. + .OUTPUTS + $true if the file is a ZIP archive, $false otherwise. +#> +function Test-IsZipFile { + Param( + [string] $Path + ) + $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant() + if ($extension -eq '.zip') { + return $true + } + # Check for ZIP magic bytes "PK" (0x50 0x4B) + # This handles the case where the file does not have a .zip extension but is still a ZIP archive (like .nupkg) + $bytes = Get-Content -Path $Path -AsByteStream -TotalCount 2 -ErrorAction SilentlyContinue + if ($bytes -and $bytes.Count -eq 2) { + return ([char]$bytes[0] -eq 'P') -and ([char]$bytes[1] -eq 'K') + } + return $false +} + +<# + .SYNOPSIS + Extracts .app files from a ZIP archive to the destination path. + .PARAMETER ZipFile + The path to the ZIP file. + .PARAMETER DestinationPath + The path where .app files should be extracted to. + .OUTPUTS + An array of paths to the extracted .app files. +#> +function Expand-ZipFileToAppFiles { + Param( + [string] $ZipFile, + [string] $DestinationPath + ) + $fileName = [System.IO.Path]::GetFileName($ZipFile) + OutputDebug -message "Expanding zip file to extract .app files: $ZipFile" + + # If file doesn't have .zip extension, copy to temp with .zip extension for Expand-Archive + $zipToExtract = $ZipFile + $tempZipCreated = $false + if ([System.IO.Path]::GetExtension($ZipFile).ToLowerInvariant() -ne '.zip') { + $zipToExtract = Join-Path $env:RUNNER_TEMP "$([System.IO.Path]::GetFileName($ZipFile)).zip" + Copy-Item -Path $ZipFile -Destination $zipToExtract + $tempZipCreated = $true + } + + try { + # Extract to runner temp folder + $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($fileName)) + Expand-Archive -Path $zipToExtract -DestinationPath $extractPath -Force + + # Find all .app files in the extracted folder and copy them to the destination + $appFiles = @() + foreach ($appFile in (Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { + $destFile = Join-Path $DestinationPath $appFile.Name + Copy-Item -Path $appFile.FullName -Destination $destFile -Force + $appFiles += $destFile + } + + # Clean up the extracted folder + Remove-Item -Path $extractPath -Recurse -Force + + if ($appFiles.Count -eq 0) { + OutputWarning -message "No .app files found in zip archive: $fileName" + } else { + OutputDebug -message "Found $($appFiles.Count) .app file(s) in zip archive" + } + return $appFiles + } + finally { + if ($tempZipCreated) { + Remove-Item -Path $zipToExtract -Force -ErrorAction SilentlyContinue + } + } +} + +<# + .SYNOPSIS + Resolves a local path to an array of .app file paths. + .DESCRIPTION + Handles local files and folders: + - If path is an .app file: returns it + - If path is a folder: recursively finds all .app files + - If path contains wildcards: resolves them to matching files + - If path is a ZIP file (by extension or magic bytes): extracts and returns .app files + .PARAMETER Path + The local file or folder path. + .PARAMETER DestinationPath + The path where extracted .app files should be placed (for ZIP files). + .OUTPUTS + An array of paths to .app files. +#> +function Get-AppFilesFromLocalPath { + Param( + [string] $Path, + [string] $DestinationPath + ) + + # Get all matching items (works for folders, wildcards, and single files) + $matchedItems = @(Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue) + + if ($matchedItems.Count -eq 0) { + OutputWarning -message "No files found at local path: $Path" + return @() + } + + # Process each matched file + $appFiles = @() + foreach ($item in $matchedItems) { + $extension = [System.IO.Path]::GetExtension($item.FullName).ToLowerInvariant() + + if ($extension -eq '.app') { + $appFiles += $item.FullName + } elseif (Test-IsZipFile -Path $item.FullName) { + $appFiles += Expand-ZipFileToAppFiles -ZipFile $item.FullName -DestinationPath $DestinationPath + } else { + OutputWarning -message "Unknown file type for local path: $($item.FullName). Skipping." + } + } + return $appFiles +} + <# .SYNOPSIS Downloads a file from a URL to a specified download path. @@ -45,31 +173,10 @@ function Get-AppFilesFromUrl { Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 - # Check if the downloaded file is a zip file - $extension = [System.IO.Path]::GetExtension($downloadedFile).ToLowerInvariant() - if ($extension -eq '.zip') { - Write-Host "Extracting .app files from zip archive: $sanitizedFileName" - - # Extract to runner temp folder - $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($sanitizedFileName)) - Expand-Archive -Path $downloadedFile -DestinationPath $extractPath -Force + # Check if the downloaded file is a zip file (by extension or magic bytes) + if (Test-IsZipFile -Path $downloadedFile) { + $appFiles = Expand-ZipFileToAppFiles -ZipFile $downloadedFile -DestinationPath $DownloadPath Remove-Item -Path $downloadedFile -Force - - # Find all .app files in the extracted folder and copy them to the download path - $appFiles = @() - foreach ($appFile in (Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { - $destFile = Join-Path $DownloadPath $appFile.Name - Copy-Item -Path $appFile.FullName -Destination $destFile -Force - $appFiles += $destFile - } - - # Clean up the extracted folder - Remove-Item -Path $extractPath -Recurse -Force - - if ($appFiles.Count -eq 0) { - throw "Zip archive '$sanitizedFileName' does not contain any .app files" - } - Write-Host "Found $($appFiles.Count) .app file(s) in zip archive" return $appFiles } @@ -84,7 +191,9 @@ function Get-AppFilesFromUrl { For each entry that is a URL (starts with http:// or https://): - Resolves any secret placeholders in the format ${{ secretName }} by looking up the secret value - Downloads the app file to the specified destination path - For entries that are not URLs (local paths), they are returned as-is. + For entries that are local paths: + - Resolves folders to their contained .app files + - Extracts .app files from ZIP archives .PARAMETER DestinationPath The path where the app files should be downloaded. .OUTPUTS @@ -105,6 +214,7 @@ function Get-DependenciesFromInstallApps { $secrets = @{} } + # Initialize the install hashtable $install = @{ "Apps" = @($settings.installApps) "TestApps" = @($settings.installTestApps) @@ -118,39 +228,42 @@ function Get-DependenciesFromInstallApps { # Replace secret names in install.apps and install.testApps and download files from URLs foreach($list in @('Apps','TestApps')) { - $install."$list" = @($install."$list" | ForEach-Object { - $appFile = $_ - # If the app file is not a URL, return it as is + $updatedListOfFiles = @() + foreach($appFile in $install."$list") { + Write-Host "Processing install$($list) entry: $appFile" + + # If the app file is not a URL, resolve local path. if ($appFile -notlike 'http*://*') { - Write-Host "install$($list) contains a local path: $appFile" - return $appFile - } + $updatedListOfFiles += Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $DestinationPath + } else { + # Else, check for secrets in the URL and replace them + $appFileUrl = $appFile + $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' + if ($appFile -match $pattern) { + $secretName = $matches[2] + if (-not $secrets.ContainsKey($secretName)) { + throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" + } + $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) + } - # Else, check for secrets in the URL and replace them - $appFileUrl = $appFile - $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' - if ($appFile -match $pattern) { - $secretName = $matches[2] - if (-not $secrets.ContainsKey($secretName)) { - throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" + # Download the file (may return multiple .app files if it's a zip) + try { + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath + } catch { + throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" } - $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) - } - # Download the file (may return multiple .app files if it's a zip) - try { - Write-Host "Downloading from URL: $appFile" - $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath - } catch { - throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" + $updatedListOfFiles += $appFiles } + } - return $appFiles - }) + # Update the install hashtable with the resolved file paths + $install."$list" = $updatedListOfFiles } return $install } -Export-ModuleMember -Function Get-AppFilesFromUrl, Get-DependenciesFromInstallApps +Export-ModuleMember -Function Get-AppFilesFromUrl, Get-AppFilesFromLocalPath, Get-DependenciesFromInstallApps diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 2c6c67e512..430efd2463 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -98,7 +98,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { $result | Should -Contain (Join-Path $downloadPath "AnotherApp.app") } - It 'Throws error when zip contains no .app files' { + It 'Returns empty array and warns when zip contains no .app files' { # Create zip with non-.app files $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNoApps" New-Item -ItemType Directory -Path $zipSourcePath | Out-Null @@ -111,7 +111,14 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { Copy-Item -Path $zipPath -Destination $OutFile -Force } -ModuleName DownloadProjectDependencies - { Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath } | Should -Throw "*does not contain any .app files*" + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*No .app files found in zip archive*" + } } It 'Handles URL with query parameters' { @@ -143,6 +150,116 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { } } +Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { + BeforeEach { + # Create a temp folder for test files + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFolder', Justification = 'False positive.')] + $testFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Create destination folder for extracted files + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'destFolder', Justification = 'False positive.')] + $destFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + } + + AfterEach { + if (Test-Path $testFolder) { + Remove-Item -Path $testFolder -Recurse -Force + } + if (Test-Path $destFolder) { + Remove-Item -Path $destFolder -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + } + + It 'Returns single .app file directly' { + $appFile = Join-Path $testFolder "MyApp.app" + [System.IO.File]::WriteAllBytes($appFile, [byte[]](1, 2, 3)) + + $result = Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $destFolder + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -Be $appFile + } + + It 'Finds all .app files in a folder recursively' { + # Create nested structure + $subFolder = New-Item -ItemType Directory -Path (Join-Path $testFolder "SubFolder") + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $subFolder "App2.app"), [byte[]](4, 5, 6)) + [System.IO.File]::WriteAllBytes((Join-Path $subFolder "NotAnApp.txt"), [byte[]](7, 8, 9)) + + $result = Get-AppFilesFromLocalPath -Path $testFolder -DestinationPath $destFolder + + @($result) | Should -HaveCount 2 + @($result) | Should -Contain (Join-Path $testFolder "App1.app") + @($result) | Should -Contain (Join-Path $subFolder "App2.app") + } + + It 'Resolves wildcard patterns' { + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App2.app"), [byte[]](4, 5, 6)) + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "Other.txt"), [byte[]](7, 8, 9)) + + $result = Get-AppFilesFromLocalPath -Path (Join-Path $testFolder "*.app") -DestinationPath $destFolder + + @($result) | Should -HaveCount 2 + } + + It 'Extracts .app files from a .nupkg file (ZIP with different extension)' { + # Create a .nupkg (which is really a ZIP) + $nupkgContentFolder = Join-Path $env:RUNNER_TEMP "NupkgContent" + New-Item -ItemType Directory -Path $nupkgContentFolder | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $nupkgContentFolder "PackagedApp.app"), [byte[]](1, 2, 3)) + + $nupkgFile = Join-Path $testFolder "MyPackage.nupkg" + Compress-Archive -Path (Join-Path $nupkgContentFolder "*") -DestinationPath $nupkgFile + + $result = Get-AppFilesFromLocalPath -Path $nupkgFile -DestinationPath $destFolder + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*PackagedApp.app" + Test-Path @($result)[0] | Should -BeTrue + } + + It 'Returns empty array for wildcard pattern with no matches' { + $result = Get-AppFilesFromLocalPath -Path (Join-Path $testFolder "*.app") -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + } + + It 'Warns when no files found at local path' { + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromLocalPath -Path "C:\NonExistent\Path.app" -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*No files found at local path*" + } + } + + It 'Warns when encountering unknown file types' { + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "readme.txt"), [byte[]](1, 2, 3)) + + $result = Get-AppFilesFromLocalPath -Path $testFolder -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*Unknown file type*" + } + } +} + Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { BeforeEach { # Create a temp download folder @@ -186,17 +303,35 @@ Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { $result.TestApps | Should -HaveCount 0 } - It 'Returns local paths unchanged' { + It 'Returns local .app files from existing paths' { + # Create temporary test files + $testAppsFolder = Join-Path $downloadPath "TestApps" + New-Item -ItemType Directory -Path $testAppsFolder | Out-Null + $testAppFile = Join-Path $testAppsFolder "MyApp.app" + [System.IO.File]::WriteAllBytes($testAppFile, [byte[]](1, 2, 3)) + $testTestAppFile = Join-Path $testAppsFolder "TestApp.app" + [System.IO.File]::WriteAllBytes($testTestAppFile, [byte[]](1, 2, 3)) + + $env:Settings = @{ + installApps = @($testAppFile) + installTestApps = @($testTestAppFile) + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -Contain $testAppFile + $result.TestApps | Should -Contain $testTestAppFile + } + + It 'Returns empty array for non-existent local paths' { $env:Settings = @{ - installApps = @("C:\Apps\MyApp.app", ".\relative\path\App.app") - installTestApps = @("C:\TestApps\TestApp.app") + installApps = @("C:\NonExistent\Path\MyApp.app") + installTestApps = @() } | ConvertTo-Json -Depth 10 $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath - $result.Apps | Should -Contain "C:\Apps\MyApp.app" - $result.Apps | Should -Contain ".\relative\path\App.app" - $result.TestApps | Should -Contain "C:\TestApps\TestApp.app" + $result.Apps | Should -HaveCount 0 } It 'Downloads apps from URLs' { From cc03d439f9ecc5d477b2cc33cbd1af09962c5eda Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 11:51:11 +0100 Subject: [PATCH 18/29] Make sure all app files are always copied to dependencies folder --- .../DownloadProjectDependencies.psm1 | 27 ++++++--- Tests/DownloadProjectDependencies.Test.ps1 | 60 +++++++++++++++---- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index 81147b3ee6..2f173c8e1d 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -1,4 +1,5 @@ Import-Module -Name (Join-Path $PSScriptRoot '../Github-Helper.psm1') +. (Join-Path -Path $PSScriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) <# .SYNOPSIS @@ -47,22 +48,30 @@ function Expand-ZipFileToAppFiles { $zipToExtract = $ZipFile $tempZipCreated = $false if ([System.IO.Path]::GetExtension($ZipFile).ToLowerInvariant() -ne '.zip') { - $zipToExtract = Join-Path $env:RUNNER_TEMP "$([System.IO.Path]::GetFileName($ZipFile)).zip" + $zipToExtract = Join-Path (GetTemporaryPath) "$([System.IO.Path]::GetFileName($ZipFile)).zip" Copy-Item -Path $ZipFile -Destination $zipToExtract $tempZipCreated = $true } try { # Extract to runner temp folder - $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileNameWithoutExtension($fileName)) + $extractPath = Join-Path (GetTemporaryPath) ([System.IO.Path]::GetFileNameWithoutExtension($fileName)) Expand-Archive -Path $zipToExtract -DestinationPath $extractPath -Force - # Find all .app files in the extracted folder and copy them to the destination + # Find all files in the extracted folder and process them $appFiles = @() - foreach ($appFile in (Get-ChildItem -Path $extractPath -Filter '*.app' -Recurse)) { - $destFile = Join-Path $DestinationPath $appFile.Name - Copy-Item -Path $appFile.FullName -Destination $destFile -Force - $appFiles += $destFile + foreach ($file in (Get-ChildItem -Path $extractPath -Recurse -File)) { + $extension = [System.IO.Path]::GetExtension($file.FullName).ToLowerInvariant() + + if ($extension -eq '.app') { + $destFile = Join-Path $DestinationPath $file.Name + Copy-Item -Path $file.FullName -Destination $destFile -Force + $appFiles += $destFile + } + elseif (Test-IsZipFile -Path $file.FullName) { + # Recursively extract nested ZIP files + $appFiles += Expand-ZipFileToAppFiles -ZipFile $file.FullName -DestinationPath $DestinationPath + } } # Clean up the extracted folder @@ -118,7 +127,9 @@ function Get-AppFilesFromLocalPath { $extension = [System.IO.Path]::GetExtension($item.FullName).ToLowerInvariant() if ($extension -eq '.app') { - $appFiles += $item.FullName + $destFile = Join-Path $DestinationPath $item.Name + Copy-Item -Path $item.FullName -Destination $destFile -Force + $appFiles += $destFile } elseif (Test-IsZipFile -Path $item.FullName) { $appFiles += Expand-ZipFileToAppFiles -ZipFile $item.FullName -DestinationPath $DestinationPath } else { diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 430efd2463..861bd4992a 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -98,6 +98,34 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { $result | Should -Contain (Join-Path $downloadPath "AnotherApp.app") } + It 'Extracts .app files from nested ZIP inside ZIP' { + # Create inner ZIP with .app file + $innerZipSource = Join-Path $env:RUNNER_TEMP "InnerZipSource" + New-Item -ItemType Directory -Path $innerZipSource -Force | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $innerZipSource "InnerApp.app"), [byte[]](1, 2, 3)) + $innerZipPath = Join-Path $env:RUNNER_TEMP "InnerApps.zip" + Compress-Archive -Path "$innerZipSource\*" -DestinationPath $innerZipPath + + # Create outer ZIP containing the inner ZIP and another .app + $outerZipSource = Join-Path $env:RUNNER_TEMP "OuterZipSource" + New-Item -ItemType Directory -Path $outerZipSource -Force | Out-Null + Copy-Item -Path $innerZipPath -Destination (Join-Path $outerZipSource "InnerApps.zip") + [System.IO.File]::WriteAllBytes((Join-Path $outerZipSource "OuterApp.app"), [byte[]](4, 5, 6)) + $outerZipPath = Join-Path $env:RUNNER_TEMP "OuterApps.zip" + Compress-Archive -Path "$outerZipSource\*" -DestinationPath $outerZipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $outerZipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/OuterApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "OuterApp.app") + $result | Should -Contain (Join-Path $downloadPath "InnerApp.app") + } + It 'Returns empty array and warns when zip contains no .app files' { # Create zip with non-.app files $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNoApps" @@ -179,17 +207,18 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { $env:RUNNER_TEMP = $originalRunnerTemp } - It 'Returns single .app file directly' { + It 'Copies single .app file to destination' { $appFile = Join-Path $testFolder "MyApp.app" [System.IO.File]::WriteAllBytes($appFile, [byte[]](1, 2, 3)) $result = Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $destFolder @($result) | Should -HaveCount 1 - @($result)[0] | Should -Be $appFile + @($result)[0] | Should -Be (Join-Path $destFolder "MyApp.app") + Test-Path @($result)[0] | Should -BeTrue } - It 'Finds all .app files in a folder recursively' { + It 'Copies all .app files from folder to destination' { # Create nested structure $subFolder = New-Item -ItemType Directory -Path (Join-Path $testFolder "SubFolder") [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App1.app"), [byte[]](1, 2, 3)) @@ -199,8 +228,11 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { $result = Get-AppFilesFromLocalPath -Path $testFolder -DestinationPath $destFolder @($result) | Should -HaveCount 2 - @($result) | Should -Contain (Join-Path $testFolder "App1.app") - @($result) | Should -Contain (Join-Path $subFolder "App2.app") + @($result) | Should -Contain (Join-Path $destFolder "App1.app") + @($result) | Should -Contain (Join-Path $destFolder "App2.app") + Test-Path (Join-Path $destFolder "App1.app") | Should -BeTrue + Test-Path (Join-Path $destFolder "App2.app") | Should -BeTrue + Test-Path (Join-Path $destFolder "App2.app") | Should -BeTrue } It 'Resolves wildcard patterns' { @@ -303,13 +335,13 @@ Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { $result.TestApps | Should -HaveCount 0 } - It 'Returns local .app files from existing paths' { - # Create temporary test files - $testAppsFolder = Join-Path $downloadPath "TestApps" - New-Item -ItemType Directory -Path $testAppsFolder | Out-Null - $testAppFile = Join-Path $testAppsFolder "MyApp.app" + It 'Copies local .app files to destination path' { + # Create temporary test files in a source folder (not the download path) + $sourceFolder = Join-Path $env:RUNNER_TEMP "SourceApps" + New-Item -ItemType Directory -Path $sourceFolder | Out-Null + $testAppFile = Join-Path $sourceFolder "MyApp.app" [System.IO.File]::WriteAllBytes($testAppFile, [byte[]](1, 2, 3)) - $testTestAppFile = Join-Path $testAppsFolder "TestApp.app" + $testTestAppFile = Join-Path $sourceFolder "TestApp.app" [System.IO.File]::WriteAllBytes($testTestAppFile, [byte[]](1, 2, 3)) $env:Settings = @{ @@ -319,8 +351,10 @@ Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath - $result.Apps | Should -Contain $testAppFile - $result.TestApps | Should -Contain $testTestAppFile + $result.Apps | Should -Contain (Join-Path $downloadPath "MyApp.app") + $result.TestApps | Should -Contain (Join-Path $downloadPath "TestApp.app") + Test-Path (Join-Path $downloadPath "MyApp.app") | Should -BeTrue + Test-Path (Join-Path $downloadPath "TestApp.app") | Should -BeTrue } It 'Returns empty array for non-existent local paths' { From 0ba2ae6a93001ff20cff4d5b27dfd6aec0268cdc Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 12:00:13 +0100 Subject: [PATCH 19/29] Update test --- Tests/DownloadProjectDependencies.Test.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 861bd4992a..260ff399c7 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -270,7 +270,9 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { It 'Warns when no files found at local path' { Mock OutputWarning {} -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromLocalPath -Path "C:\NonExistent\Path.app" -DestinationPath $destFolder + # Use a cross-platform path + $nonExistentPath = Join-Path $testFolder "NonExistent" "Path.app" + $result = Get-AppFilesFromLocalPath -Path $nonExistentPath -DestinationPath $destFolder @($result) | Should -HaveCount 0 Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { @@ -358,8 +360,10 @@ Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { } It 'Returns empty array for non-existent local paths' { + # Use a path that works cross-platform + $nonExistentPath = Join-Path $downloadPath "NonExistent" "MyApp.app" $env:Settings = @{ - installApps = @("C:\NonExistent\Path\MyApp.app") + installApps = @($nonExistentPath) installTestApps = @() } | ConvertTo-Json -Depth 10 From 25d5c241fd2fee984c4c829b6acf46c244efb014 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 12:04:10 +0100 Subject: [PATCH 20/29] Fix compatibility issue --- .../DownloadProjectDependencies.psm1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index 2f173c8e1d..91d322882d 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -19,7 +19,11 @@ function Test-IsZipFile { } # Check for ZIP magic bytes "PK" (0x50 0x4B) # This handles the case where the file does not have a .zip extension but is still a ZIP archive (like .nupkg) - $bytes = Get-Content -Path $Path -AsByteStream -TotalCount 2 -ErrorAction SilentlyContinue + if ($PSVersionTable.PSVersion.Major -ge 6) { + $bytes = Get-Content -Path $Path -AsByteStream -TotalCount 2 -ErrorAction SilentlyContinue + } else { + $bytes = Get-Content -Path $Path -Encoding Byte -TotalCount 2 -ErrorAction SilentlyContinue + } if ($bytes -and $bytes.Count -eq 2) { return ([char]$bytes[0] -eq 'P') -and ([char]$bytes[1] -eq 'K') } From 8995cc840cdb6a9c8e11528be37d08e65ef541c2 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 12:09:25 +0100 Subject: [PATCH 21/29] Fix test --- Tests/DownloadProjectDependencies.Test.ps1 | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 260ff399c7..1b1967b6fc 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -246,13 +246,15 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { } It 'Extracts .app files from a .nupkg file (ZIP with different extension)' { - # Create a .nupkg (which is really a ZIP) + # Create a .nupkg (which is really a ZIP) - create as .zip first, then rename for PS5 compatibility $nupkgContentFolder = Join-Path $env:RUNNER_TEMP "NupkgContent" New-Item -ItemType Directory -Path $nupkgContentFolder | Out-Null [System.IO.File]::WriteAllBytes((Join-Path $nupkgContentFolder "PackagedApp.app"), [byte[]](1, 2, 3)) + $tempZipFile = Join-Path $testFolder "MyPackage.zip" + Compress-Archive -Path (Join-Path $nupkgContentFolder "*") -DestinationPath $tempZipFile $nupkgFile = Join-Path $testFolder "MyPackage.nupkg" - Compress-Archive -Path (Join-Path $nupkgContentFolder "*") -DestinationPath $nupkgFile + Move-Item -Path $tempZipFile -Destination $nupkgFile $result = Get-AppFilesFromLocalPath -Path $nupkgFile -DestinationPath $destFolder @@ -270,8 +272,8 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { It 'Warns when no files found at local path' { Mock OutputWarning {} -ModuleName DownloadProjectDependencies - # Use a cross-platform path - $nonExistentPath = Join-Path $testFolder "NonExistent" "Path.app" + # Use a cross-platform path (nested Join-Path for PS5 compatibility) + $nonExistentPath = Join-Path (Join-Path $testFolder "NonExistent") "Path.app" $result = Get-AppFilesFromLocalPath -Path $nonExistentPath -DestinationPath $destFolder @($result) | Should -HaveCount 0 @@ -360,8 +362,8 @@ Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { } It 'Returns empty array for non-existent local paths' { - # Use a path that works cross-platform - $nonExistentPath = Join-Path $downloadPath "NonExistent" "MyApp.app" + # Use a path that works cross-platform (nested Join-Path for PS5 compatibility) + $nonExistentPath = Join-Path (Join-Path $downloadPath "NonExistent") "MyApp.app" $env:Settings = @{ installApps = @($nonExistentPath) installTestApps = @() From 488057f2c4d2266357f4d4710d84457add09a80d Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 13:17:56 +0100 Subject: [PATCH 22/29] debugging --- .../DownloadProjectDependencies.psm1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index 91d322882d..80b5e2072b 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -187,6 +187,7 @@ function Get-AppFilesFromUrl { Invoke-CommandWithRetry -ScriptBlock { Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 + OutputDebug -message "Downloaded file to path: $downloadedFile" # Check if the downloaded file is a zip file (by extension or magic bytes) if (Test-IsZipFile -Path $downloadedFile) { @@ -264,11 +265,11 @@ function Get-DependenciesFromInstallApps { } # Download the file (may return multiple .app files if it's a zip) - try { - $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath - } catch { - throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" - } + #try { + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath + #} catch { + # throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" + #} $updatedListOfFiles += $appFiles } From 7d393cec135e3e3794dba912d95c52fdbe1583bf Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 13:21:22 +0100 Subject: [PATCH 23/29] Check if folder exists --- .../DownloadProjectDependencies.psm1 | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index 80b5e2072b..f5e1513fc6 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -117,6 +117,11 @@ function Get-AppFilesFromLocalPath { [string] $DestinationPath ) + # Ensure the destination directory exists + if (-not (Test-Path -Path $DestinationPath)) { + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + } + # Get all matching items (works for folders, wildcards, and single files) $matchedItems = @(Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue) @@ -162,6 +167,12 @@ function Get-AppFilesFromUrl { [string] $Url, [string] $DownloadPath ) + + # Ensure the download directory exists + if (-not (Test-Path -Path $DownloadPath)) { + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + } + # Get the file name from the URL $urlWithoutQuery = $Url.Split('?')[0].TrimEnd('/') $rawFileName = [System.IO.Path]::GetFileName($urlWithoutQuery) From 8f660fe23ee9772b2c4be1be5baeeb2b5f7d0582 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 13:38:23 +0100 Subject: [PATCH 24/29] Better logging --- .../DownloadProjectDependencies.psm1 | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index f5e1513fc6..aa2ba5462d 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -157,6 +157,8 @@ function Get-AppFilesFromLocalPath { If the downloaded file is a zip file, it extracts the .app files from it. .PARAMETER Url The URL of the file to download. + .PARAMETER CleanUrl + The original URL for error reporting. .PARAMETER DownloadPath The path where the file should be downloaded. .OUTPUTS @@ -165,6 +167,7 @@ function Get-AppFilesFromLocalPath { function Get-AppFilesFromUrl { Param( [string] $Url, + [string] $CleanUrl, [string] $DownloadPath ) @@ -195,10 +198,14 @@ function Get-AppFilesFromUrl { } # Download with retry logic - Invoke-CommandWithRetry -ScriptBlock { - Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null - } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 - OutputDebug -message "Downloaded file to path: $downloadedFile" + try { + Invoke-CommandWithRetry -ScriptBlock { + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null + } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 + OutputDebug -message "Downloaded file to path: $downloadedFile" + } catch { + throw "Failed to download file from inaccessible URL: $CleanUrl. Error was: $($_.Exception.Message)" + } # Check if the downloaded file is a zip file (by extension or magic bytes) if (Test-IsZipFile -Path $downloadedFile) { @@ -276,11 +283,7 @@ function Get-DependenciesFromInstallApps { } # Download the file (may return multiple .app files if it's a zip) - #try { - $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -DownloadPath $DestinationPath - #} catch { - # throw "Setting: install$($list) contains an inaccessible URL: $appFile. Error was: $($_.Exception.Message)" - #} + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -CleanUrl $appFile -DownloadPath $DestinationPath $updatedListOfFiles += $appFiles } From 8c6e34ec7f764dcfc1bb4e2465244f41d94b0840 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 13:44:49 +0100 Subject: [PATCH 25/29] Fix trailing whitespace and missing blank line --- .../DownloadProjectDependencies.psm1 | 2 +- RELEASENOTES.md | 3 ++- Tests/DownloadProjectDependencies.Test.ps1 | 6 +++--- out/InstallAppsTestRepo | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) create mode 160000 out/InstallAppsTestRepo diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index aa2ba5462d..750523dedf 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -267,7 +267,7 @@ function Get-DependenciesFromInstallApps { foreach($appFile in $install."$list") { Write-Host "Processing install$($list) entry: $appFile" - # If the app file is not a URL, resolve local path. + # If the app file is not a URL, resolve local path. if ($appFile -notlike 'http*://*') { $updatedListOfFiles += Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $DestinationPath } else { diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d949307b9f..38d4506167 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -21,7 +21,8 @@ Now, the workflow will fail with a clear error message if the specified environm ### Improving error detection and build reliability when downloading project dependencies The `DownloadProjectDependencies` action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings upfront, rather than validating URLs at build time. This change provides: -- Earlier detection of inaccessible or misconfigured URLs + +- Earlier detectionof inaccessible or misconfigured URLs - Clearer error messages when secrets are missing or URLs are invalid - Warnings for potential issues like duplicate filenames or insecure HTTP connections diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 1b1967b6fc..1df7c999a9 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -37,7 +37,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { } It 'Downloads a single .app file from URL' { - # Mock Invoke-WebRequest at module level - this works because Invoke-CommandWithRetry + # Mock Invoke-WebRequest at module level - this works because Invoke-CommandWithRetry # calls Invoke-WebRequest with a scriptblock that runs in the module scope Mock Invoke-WebRequest { param($Method, $UseBasicParsing, $Uri, $OutFile) @@ -250,8 +250,8 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { $nupkgContentFolder = Join-Path $env:RUNNER_TEMP "NupkgContent" New-Item -ItemType Directory -Path $nupkgContentFolder | Out-Null [System.IO.File]::WriteAllBytes((Join-Path $nupkgContentFolder "PackagedApp.app"), [byte[]](1, 2, 3)) - - $tempZipFile = Join-Path $testFolder "MyPackage.zip" + + $tempZipFile= Join-Path $testFolder "MyPackage.zip" Compress-Archive -Path (Join-Path $nupkgContentFolder "*") -DestinationPath $tempZipFile $nupkgFile = Join-Path $testFolder "MyPackage.nupkg" Move-Item -Path $tempZipFile -Destination $nupkgFile diff --git a/out/InstallAppsTestRepo b/out/InstallAppsTestRepo new file mode 160000 index 0000000000..8374f0bd9b --- /dev/null +++ b/out/InstallAppsTestRepo @@ -0,0 +1 @@ +Subproject commit 8374f0bd9b08af86c97404666675b6cb183490f9 From 4b9cf82fe1386a65097dfbc1a200b441494fa37d Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 13:45:52 +0100 Subject: [PATCH 26/29] remove out folder --- out/InstallAppsTestRepo | 1 - 1 file changed, 1 deletion(-) delete mode 160000 out/InstallAppsTestRepo diff --git a/out/InstallAppsTestRepo b/out/InstallAppsTestRepo deleted file mode 160000 index 8374f0bd9b..0000000000 --- a/out/InstallAppsTestRepo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8374f0bd9b08af86c97404666675b6cb183490f9 From 697edbb92df8a890851c09d5af57994ff23b8950 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 14:06:30 +0100 Subject: [PATCH 27/29] Change directory --- .../DownloadProjectDependencies.Action.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index 564727d58b..1fe72a7775 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -126,7 +126,9 @@ $downloadedDependencies += DownloadDependenciesFromProbingPaths -baseFolder $bas Write-Host "::endgroup::" Write-Host "::group::Downloading dependencies from settings (installApps and installTestApps)" +Push-Location -Path (Join-Path $baseFolder $project) #Change to the project folder because installApps paths are relative to the project folder $settingsDependencies = Get-DependenciesFromInstallApps -DestinationPath $destinationPath +Pop-Location Write-Host "::endgroup::" $downloadedApps = @() From ff159c2eff7310edc4710d377d326b60c38706c0 Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 14:58:33 +0100 Subject: [PATCH 28/29] Suggestions batch 1 --- .../DownloadProjectDependencies.psm1 | 2 +- RELEASENOTES.md | 2 +- Tests/DownloadProjectDependencies.Test.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 index 750523dedf..c43be0d36b 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -194,7 +194,7 @@ function Get-AppFilesFromUrl { # Get the final file path $downloadedFile = Join-Path $DownloadPath $sanitizedFileName if (Test-Path -LiteralPath $downloadedFile) { - OutputDebug -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." + OutputWarning -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." } # Download with retry logic diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 38d4506167..2b23f8a0c7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,7 +22,7 @@ Now, the workflow will fail with a clear error message if the specified environm The `DownloadProjectDependencies` action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings upfront, rather than validating URLs at build time. This change provides: -- Earlier detectionof inaccessible or misconfigured URLs +- Earlier detection of inaccessible or misconfigured URLs - Clearer error messages when secrets are missing or URLs are invalid - Warnings for potential issues like duplicate filenames or insecure HTTP connections diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 1df7c999a9..341e472207 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -232,7 +232,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { @($result) | Should -Contain (Join-Path $destFolder "App2.app") Test-Path (Join-Path $destFolder "App1.app") | Should -BeTrue Test-Path (Join-Path $destFolder "App2.app") | Should -BeTrue - Test-Path (Join-Path $destFolder "App2.app") | Should -BeTrue + Test-Path (Join-Path $destFolder "NotAnApp.txt") | Should -BeFalse } It 'Resolves wildcard patterns' { From 324518cb9ff2c72c73524bb07db03df1ac2e62cc Mon Sep 17 00:00:00 2001 From: aholstrup1 Date: Wed, 4 Feb 2026 15:06:00 +0100 Subject: [PATCH 29/29] suggestions batch 2 --- RELEASENOTES.md | 2 +- Tests/DownloadProjectDependencies.Test.ps1 | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2b23f8a0c7..224eeabd4f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -24,7 +24,7 @@ The `DownloadProjectDependencies` action now downloads app files from URLs speci - Earlier detection of inaccessible or misconfigured URLs - Clearer error messages when secrets are missing or URLs are invalid -- Warnings for potential issues like duplicate filenames or insecure HTTP connections +- Warnings for potential issues like duplicate filenames ### Set default values for workflow inputs diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 index 341e472207..6a77f375e8 100644 --- a/Tests/DownloadProjectDependencies.Test.ps1 +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -44,7 +44,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) } -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app" -CleanUrl "https://example.com/downloads/TestApp.app" -DownloadPath $downloadPath @($result) | Should -HaveCount 1 @($result)[0] | Should -BeLike "*TestApp.app" @@ -65,7 +65,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { Copy-Item -Path $zipPath -Destination $OutFile -Force } -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApps.zip" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApps.zip" -CleanUrl "https://example.com/downloads/TestApps.zip" -DownloadPath $downloadPath $result | Should -HaveCount 2 $result | Should -Contain (Join-Path $downloadPath "App1.app") @@ -91,7 +91,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { Copy-Item -Path $zipPath -Destination $OutFile -Force } -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NestedApps.zip" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NestedApps.zip" -CleanUrl "https://example.com/downloads/NestedApps.zip" -DownloadPath $downloadPath $result | Should -HaveCount 2 $result | Should -Contain (Join-Path $downloadPath "NestedApp.app") @@ -119,7 +119,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { Copy-Item -Path $outerZipPath -Destination $OutFile -Force } -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/OuterApps.zip" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/OuterApps.zip" -CleanUrl "https://example.com/downloads/OuterApps.zip" -DownloadPath $downloadPath $result | Should -HaveCount 2 $result | Should -Contain (Join-Path $downloadPath "OuterApp.app") @@ -141,7 +141,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { Mock OutputWarning {} -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -CleanUrl "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath @($result) | Should -HaveCount 0 Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { @@ -155,7 +155,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) } -ModuleName DownloadProjectDependencies - $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -CleanUrl "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -DownloadPath $downloadPath @($result) | Should -HaveCount 1 @($result)[0] | Should -BeLike "*TestApp.app" @@ -168,7 +168,7 @@ Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { } -ModuleName DownloadProjectDependencies # URL with only spaces/invalid chars as filename (after sanitization becomes empty) - $result = Get-AppFilesFromUrl -Url "https://example.com/%20%20%20" -DownloadPath $downloadPath + $result = Get-AppFilesFromUrl -Url "https://example.com/%20%20%20" -CleanUrl "https://example.com/%20%20%20" -DownloadPath $downloadPath @($result) | Should -HaveCount 1 @($result)[0] | Should -Match "\.app$"