diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 4a1cd415d3..e35f547fe9 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -805,5 +805,18 @@ }, "description": "An array of workflow input default values. See https://aka.ms/ALGoSettings#workflowDefaultInputs" } + }, + "patternProperties": { + "^DeployTo": { + "type": "object", + "description": "Structure with additional properties for the environment specified. See https://aka.ms/ALGoSettings#deployto", + "properties": { + "unpublishOldVersions": { + "type": "boolean", + "default": false, + "description": "When set to true, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment. Only applies to PTE deployments (Scope PTE) and is non-fatal. See https://aka.ms/ALGoSettings#deployto" + } + } + } } } diff --git a/Actions/Deploy/Deploy.ps1 b/Actions/Deploy/Deploy.ps1 index 7851dcd5c0..c3f6d0527f 100644 --- a/Actions/Deploy/Deploy.ps1 +++ b/Actions/Deploy/Deploy.ps1 @@ -213,6 +213,11 @@ else { Write-Host "Publishing apps using automation API" Publish-PerTenantExtensionApps @parameters + + if (($deploymentSettings['unpublishOldVersions'] -is [bool]) -and $deploymentSettings['unpublishOldVersions']) { + Write-Host "Unpublishing old app versions" + UnpublishOldAppVersions -bcAuthContext $bcAuthContext -environment $deploymentSettings.EnvironmentName -appFiles $apps + } } } } diff --git a/Actions/Deploy/Deploy.psm1 b/Actions/Deploy/Deploy.psm1 index 900791451d..80cf54c774 100644 --- a/Actions/Deploy/Deploy.psm1 +++ b/Actions/Deploy/Deploy.psm1 @@ -242,6 +242,103 @@ function CheckInstalledApps { } } +<# + .SYNOPSIS + Unpublish old, uninstalled versions of the deployed apps from a Business Central environment. + .DESCRIPTION + After a new version of a Per Tenant Extension is installed, previous versions remain published (but uninstalled) + and clutter Extension Management. This function unpublishes those old versions using the automation API v2.0 + Microsoft.NAV.unpublish action. Only versions that are not installed AND older than a currently installed version + of the same app (matched by app id) are unpublished. This is only supported for PTE deployments (automation API). + The function is non-fatal: any failure is reported as a warning and never fails the deployment. + .PARAMETER bcAuthContext + The Business Central authentication context. + .PARAMETER environment + The environment to unpublish old app versions from. + .PARAMETER appFiles + The list of deployed app files. Only the app id is read from these files to identify which apps to clean up; + for each such app, published versions are compared against the version currently installed in the environment + (not the deployed artifact version), and uninstalled versions older than the installed version are unpublished. +#> +function UnpublishOldAppVersions { + Param( + [hashtable] $bcAuthContext, + [string] $environment, + $appFiles + ) + OutputDebugFunctionCall + + try { + # Deployed app identities (id + version) read from the .app files + $deployedApps = @($appFiles | ForEach-Object { + $appJson = Get-AppJsonFromAppFile -appFile $_ + [PSCustomObject]@{ Id = $appJson.id; Version = [version]$appJson.version } + }) + if ($deployedApps.Count -eq 0) { + return + } + + $authContext = Renew-BcAuthContext -bcAuthContext $bcAuthContext + $headers = @{ "Authorization" = "Bearer $($authContext.AccessToken)" } + $automationApiUrl = "$($bcContainerHelperConfig.apiBaseUrl.TrimEnd('/'))/v2.0/$environment/api/microsoft/automation/v2.0" + + $companies = (Invoke-RestMethod -Method Get -Uri "$automationApiUrl/companies" -Headers $headers -UseBasicParsing).value + if (-not $companies) { + OutputWarning -message "Could not find any company in environment $environment - skipping unpublish of old app versions." + return + } + $companyId = $companies[0].id + $companyUrl = "$automationApiUrl/companies($companyId)" + $extensions = @((Invoke-RestMethod -Method Get -Uri "$companyUrl/extensions" -Headers $headers -UseBasicParsing).value) + + $application = $extensions | Where-Object { $_.displayName -eq 'Application' -and $_.isInstalled } | Select-Object -First 1 + if (-not $application) { + OutputWarning -message "Could not determine the Business Central version in environment $environment - skipping unpublish of old app versions." + return + } + $applicationVersion = [version]::new($application.versionMajor, $application.versionMinor, $application.versionBuild, $application.versionRevision) + if ($applicationVersion -lt [version]'25.4.0.0') { + OutputWarning -message "Unpublishing old app versions requires Business Central 25.4 or later; environment $environment is running $applicationVersion." + return + } + + foreach($deployed in $deployedApps) { + # All published versions of this app (installed and uninstalled) + $matching = @($extensions | Where-Object { $_.id -eq $deployed.Id }) + if ($matching.Count -le 1) { + # Only one (or no) published version - nothing to clean up + continue + } + # Use the currently installed version as the cleanup threshold. The environment may have a newer + # version installed than the deployed artifact, which Publish-PerTenantExtensionApps treats as success. + $installedVersions = @($matching | Where-Object { $_.isInstalled } | ForEach-Object { [version]::new($_.versionMajor, $_.versionMinor, $_.versionBuild, $_.versionRevision) }) + if ($installedVersions.Count -eq 0) { + # No installed version - nothing to clean up against + continue + } + $installedVersion = @($installedVersions | Sort-Object -Descending)[0] + foreach($old in $matching) { + $oldVersion = [version]::new($old.versionMajor, $old.versionMinor, $old.versionBuild, $old.versionRevision) + if ($old.isInstalled -or $oldVersion -ge $installedVersion) { + # Keep anything still installed and any version at or above the installed version + continue + } + Write-Host "Unpublishing $($old.displayName) v$oldVersion" + try { + Invoke-RestMethod -Method Post -Headers $headers -Body '{}' -ContentType 'application/json' -UseBasicParsing ` + -Uri "$companyUrl/extensions($($old.packageId))/Microsoft.NAV.unpublish" | Out-Null + } + catch { + OutputWarning -message "Failed to unpublish $($old.displayName) v$($oldVersion): $($_.Exception.Message)" + } + } + } + } + catch { + OutputWarning -message "Unpublishing old app versions in environment $environment failed: $($_.Exception.Message)" + } +} + <# .SYNOPSIS Install or upgrade apps in Business Central. diff --git a/Actions/DetermineDeploymentEnvironments/DetermineDeploymentEnvironments.ps1 b/Actions/DetermineDeploymentEnvironments/DetermineDeploymentEnvironments.ps1 index da2effb98a..6d095471bd 100644 --- a/Actions/DetermineDeploymentEnvironments/DetermineDeploymentEnvironments.ps1 +++ b/Actions/DetermineDeploymentEnvironments/DetermineDeploymentEnvironments.ps1 @@ -131,6 +131,7 @@ if (!($environments)) { "ppEnvironmentUrl" = '' "includeTestAppsInSandboxEnvironment" = $false "excludeAppIds" = @() + "unpublishOldVersions" = $false } } $unknownEnvironment = 1 @@ -174,6 +175,7 @@ else { "ppEnvironmentUrl" = '' "includeTestAppsInSandboxEnvironment" = $false "excludeAppIds" = @() + "unpublishOldVersions" = $false } # Check DeployTo setting diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 67f72ce759..169e225df4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,7 @@ +### New `unpublishOldVersions` setting for deployment + +The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). When enabled, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment, keeping Extension Management clean. This only applies to PTE deployments (Scope PTE / automation API), uses the Automation API v2.0 `Microsoft.NAV.unpublish` action, and is non-fatal (failures are reported as warnings and never fail the deployment). + ### New `doNotPerformUpgrade` setting AL-Go now supports a new `doNotPerformUpgrade` setting that is passed through to `Run-AlPipeline`. Use it to skip the upgrade phase while still running the rest of the pipeline. diff --git a/Scenarios/settings.md b/Scenarios/settings.md index c0118f92d4..6ad3b6ed9e 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -75,7 +75,7 @@ The repository settings are only read from the repository settings file (.github | githubRunnerShell | Specifies which shell is used for build jobs in workflows including a build job. The default is to use the same as defined in **shell**. If the shell setting isn't defined, **powershell** is the default, which results in using _PowerShell 5.1_. Use **pwsh** for _PowerShell 7_. | | environments | Array of logical environment names. You can specify environments in GitHub environments or in the repo settings file. If you specify environments in the settings file, you can create your AUTHCONTEXT secret using **\\_AUTHCONTEXT**. You can specify additional information about environments in a setting called **DeployTo\** | | DeliverTo\ | Structure with additional properties for the deliveryTarget specified. Some properties are deliveryTarget specific. The structure can contain the following properties:
**Branches** = an array of branch patterns, which are allowed to deliver to this deliveryTarget. (Default main)
**CreateContainerIfNotExist** = *[Only for DeliverToStorage]* Create Blob Storage Container if it doesn't already exist. (Default false)
| -| DeployTo\ | Structure with additional properties for the environment specified. `` refers to the GitHub environment name. The structure can contain the following properties:
**EnvironmentType** = specifies the type of environment. The environment type can be used to invoke a custom deployment. (Default SaaS)
**EnvironmentName** = specifies the "real" name of the environment if it differs from the GitHub environment.
**Branches** = an array of branch patterns, which are allowed to deploy to this environment. These branches can also be defined under the environment in GitHub settings and both settings are honored. If neither setting is defined, the default is the **main** branch only.
**Projects** = In multi-project repositories, this property can be a comma separated list of project patterns to deploy to this environment. (Default \*)
**DependencyInstallMode** = Determines how dependencies are deployed if `GenerateDependencyArtifact` is true. Default value is `install` to install dependencies if not already installed. Other values are `ignore` for ignoring dependencies and `upgrade` or `forceUpgrade` for upgrading dependencies.
**includeTestAppsInSandboxEnvironment** = deploys test apps and their dependencies if the environment type is sandbox (Default is `false`)
**excludeAppIds** = array of app ids to exclude from deployment. (Default is `[]`)
**Scope** = Determines the mechanism for deployment to the environment (Dev or PTE). If not specified, AL-Go for GitHub will always use the Dev Scope for AppSource Apps, but also for PTEs when deploying to sandbox environments when impersonation (refreshtoken) is used for authentication.
**SyncMode** = ForceSync if deployment to this environment should happen with ForceSync, else Add. If deploying to the development endpoint you can also specify Development or Clean. (Default Add)
**BuildMode** = specifies which buildMode to use for the deployment. Default is to use the Default buildMode
**ContinuousDeployment** = true if this environment should be used for continuous deployment, else false. (Default: AL-Go will continuously deploy to sandbox environments or environments, which doesn't end in (PROD) or (FAT)
**runs-on** = specifies which runner to use when deploying to this environment. (Default is settings.runs-on)
**shell** = specifies which shell to use when deploying to this environment, pwsh or powershell. (Default is settings.shell)
**companyId** = Company Id from Business Central (for PowerPlatform connection)
**ppEnvironmentUrl** = Url of the PowerPlatform environment to deploy to
| +| DeployTo\ | Structure with additional properties for the environment specified. `` refers to the GitHub environment name. The structure can contain the following properties:
**EnvironmentType** = specifies the type of environment. The environment type can be used to invoke a custom deployment. (Default SaaS)
**EnvironmentName** = specifies the "real" name of the environment if it differs from the GitHub environment.
**Branches** = an array of branch patterns, which are allowed to deploy to this environment. These branches can also be defined under the environment in GitHub settings and both settings are honored. If neither setting is defined, the default is the **main** branch only.
**Projects** = In multi-project repositories, this property can be a comma separated list of project patterns to deploy to this environment. (Default \*)
**DependencyInstallMode** = Determines how dependencies are deployed if `GenerateDependencyArtifact` is true. Default value is `install` to install dependencies if not already installed. Other values are `ignore` for ignoring dependencies and `upgrade` or `forceUpgrade` for upgrading dependencies.
**includeTestAppsInSandboxEnvironment** = deploys test apps and their dependencies if the environment type is sandbox (Default is `false`)
**excludeAppIds** = array of app ids to exclude from deployment. (Default is `[]`)
**Scope** = Determines the mechanism for deployment to the environment (Dev or PTE). If not specified, AL-Go for GitHub will always use the Dev Scope for AppSource Apps, but also for PTEs when deploying to sandbox environments when impersonation (refreshtoken) is used for authentication.
**SyncMode** = ForceSync if deployment to this environment should happen with ForceSync, else Add. If deploying to the development endpoint you can also specify Development or Clean. (Default Add)
**unpublishOldVersions** = When set to `true`, AL-Go will unpublish old, uninstalled versions of the deployed apps from the environment after a successful deployment, to keep Extension Management clean. Only applies to PTE deployments (Scope PTE / automation API) and is non-fatal (failures are reported as warnings). (Default false)
**BuildMode** = specifies which buildMode to use for the deployment. Default is to use the Default buildMode
**ContinuousDeployment** = true if this environment should be used for continuous deployment, else false. (Default: AL-Go will continuously deploy to sandbox environments or environments, which doesn't end in (PROD) or (FAT)
**runs-on** = specifies which runner to use when deploying to this environment. (Default is settings.runs-on)
**shell** = specifies which shell to use when deploying to this environment, pwsh or powershell. (Default is settings.shell)
**companyId** = Company Id from Business Central (for PowerPlatform connection)
**ppEnvironmentUrl** = Url of the PowerPlatform environment to deploy to
| | alDoc | Structure with properties for the aldoc reference document generation. The structure can contain the following properties:
**continuousDeployment** = Determines if reference documentation will be deployed continuously as part of CI/CD. You can run the **Deploy Reference Documentation** workflow to deploy manually or on a schedule. (Default false)
**deployToGitHubPages** = Determines whether or not the reference documentation site should be deployed to GitHub Pages for the repository. In order to deploy to GitHub Pages, GitHub Pages must be enabled and set to GitHub Actuibs. (Default true)
**maxReleases** = Maximum number of releases to include in the reference documentation. (Default 3)
**groupByProject** = Determines whether projects in multi-project repositories are used as folders in reference documentation
**includeProjects** = An array of projects to include in the reference documentation. (Default all)
**excludeProjects** = An array of projects to exclude in the reference documentation. (Default none)
**header** = Header for the documentation site. (Default: Documentation for...)
**footer** = Footer for the documentation site. (Default: Made with...)
**defaultIndexMD** = Markdown for the landing page of the documentation site. (Default: Reference documentation...)
**defaultReleaseMD** = Markdown for the landing page of the release sites. (Default: Release reference documentation...)
*Note that in header, footer, defaultIndexMD and defaultReleaseMD you can use the following placeholders: {REPOSITORY}, {VERSION}, {INDEXTEMPLATERELATIVEPATH}, {RELEASENOTES}* | | useProjectDependencies | Determines whether your projects are built using a multi-stage built workflow or single stage. After setting useProjectDependencies to true, you need to run Update AL-Go System Files and your workflows including a build job will change to have multiple build jobs, depending on each other. The number of build jobs will be determined by the dependency depth in your projects.
You can change dependencies between your projects, but if the dependency **depth** changes, AL-Go will warn you that updates for your AL-Go System Files are available and you will need to run the workflow. | | CICDPushBranches | CICDPushBranches can be specified as an array of branches, which triggers a CI/CD workflow on commit. You need to run the Update AL-Go System Files workflow for the change to take effect.
Default is [ "main", "release/\*", "feature/\*" ]

**Supported release branch naming formats:**
When using release branches, AL-Go supports various naming conventions for matching previous releases during upgrade testing. The following formats are recognized:
• `releases/26` - matches releases with major version 26
• `releases/26.x` - matches releases with major version 26
• `releases/26x` - matches releases with major version 26
• `releases/v26` - matches releases with major version 26
• `releases/v26.x` - matches releases with major version 26
• `releases/v26x` - matches releases with major version 26
• `releases/26.3` - matches releases with major.minor version 26.3
The same patterns work with the singular `release/` prefix. | diff --git a/Tests/Deploy.Action.Test.ps1 b/Tests/Deploy.Action.Test.ps1 index 13a417a826..38548db5e6 100644 --- a/Tests/Deploy.Action.Test.ps1 +++ b/Tests/Deploy.Action.Test.ps1 @@ -24,6 +24,68 @@ Describe "Deploy Action Tests" { YamlTest -scriptRoot $scriptRoot -actionName $actionName -actionScript $actionScript -outputs $outputs } - # Call action + Context "unpublishOldVersions wiring" { + BeforeAll { + Import-Module (Join-Path $scriptRoot "Deploy.psm1") -Force + . (Join-Path $PSScriptRoot "..\Actions\AL-Go-Helper.ps1" -Resolve) + DownloadAndImportBcContainerHelper -baseFolder $([System.IO.Path]::GetTempPath()) + function InvokeDeploy { + Param([bool] $sandbox = $true, [hashtable] $deploymentSettings) + $json = @{ "test" = $deploymentSettings } | ConvertTo-Json -Depth 10 -Compress + $authContext = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('{"refreshToken":"dummy"}')) + $env:Secrets = @{ "test-AuthContext" = ""; "test_AuthContext" = ""; "AuthContext" = $authContext } | ConvertTo-Json -Compress + $env:Settings = @{ "type" = "PTE"; "runs-on" = "ubuntu-latest"; "shell" = "pwsh" } | ConvertTo-Json -Compress + Mock Invoke-RestMethod { return @{ "Status" = "Ready"; "environmentType" = (@{ $true = 1; $false = 0 }[$sandbox]) } } + . $scriptPath -token 'dummy' -environmentName 'test' -artifactsFolder 'artifacts' -type 'CD' -deploymentEnvironmentsJson $json + } + } + + BeforeEach { + $env:GITHUB_OUTPUT = [System.IO.Path]::GetTempFileName() + $env:GITHUB_WORKSPACE = Join-Path ([System.IO.Path]::GetTempPath()) ([GUID]::NewGuid().ToString()) + New-Item -Path $env:GITHUB_WORKSPACE -ItemType Directory | Out-Null + New-Item -Path (Join-Path $env:GITHUB_WORKSPACE '.github') -ItemType Directory | Out-Null + + $script:publishPTECalled = $false + $script:unpublishAfterPublish = $false + + Mock New-BcAuthContext { return @{ "tenantId" = "test-tenant" } } + Mock GetAppsAndDependenciesFromArtifacts { return @('/tmp/app1.app'), @() } + Mock Sort-AppFilesByDependencies { } + Mock CheckInstalledApps { } + Mock Publish-PerTenantExtensionApps { $script:publishPTECalled = $true } + Mock Publish-BcContainerApp { } + Mock UnpublishOldAppVersions { $script:unpublishAfterPublish = $script:publishPTECalled } + } + + AfterEach { + Set-Location $PSScriptRoot + Remove-Item $env:GITHUB_OUTPUT -Force -ErrorAction SilentlyContinue + Remove-Item $env:GITHUB_WORKSPACE -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Invokes cleanup after PTE publish when unpublishOldVersions is true' { + InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $true } + + Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 + Assert-MockCalled UnpublishOldAppVersions -Exactly 1 + $script:unpublishAfterPublish | Should -BeTrue + } + + It 'Does not invoke cleanup when unpublishOldVersions is not set (default)' { + InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "PTE"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @() } + + Assert-MockCalled Publish-PerTenantExtensionApps -Exactly 1 + Assert-MockCalled UnpublishOldAppVersions -Times 0 + } + + It 'Does not invoke cleanup for Dev scope even when unpublishOldVersions is true' { + InvokeDeploy -deploymentSettings @{ "EnvironmentType" = "SaaS"; "EnvironmentName" = "test"; "Branches" = @(); "Projects" = "*"; "DependencyInstallMode" = "ignore"; "SyncMode" = $null; "Scope" = "Dev"; "continuousDeployment" = $true; "includeTestAppsInSandboxEnvironment" = $false; "excludeAppIds" = @(); "unpublishOldVersions" = $true } + + Assert-MockCalled Publish-BcContainerApp -Exactly 1 + Assert-MockCalled Publish-PerTenantExtensionApps -Times 0 + Assert-MockCalled UnpublishOldAppVersions -Times 0 + } + } } diff --git a/Tests/DetermineDeploymentEnvironments.Test.ps1 b/Tests/DetermineDeploymentEnvironments.Test.ps1 index 6b90ef76ad..f7d6aa3609 100644 --- a/Tests/DetermineDeploymentEnvironments.Test.ps1 +++ b/Tests/DetermineDeploymentEnvironments.Test.ps1 @@ -59,13 +59,13 @@ Describe "DetermineDeploymentEnvironments Action Test" { . (Join-Path $scriptRoot $scriptName) -getEnvironments '*' -type 'CD' PassGeneratedOutput $EnvironmentsMatrixJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"matrix"=@{"include"=@(@{"environment"="another";"os"="[""ubuntu-latest""]";"shell"="pwsh";"buildMode"="Default"};@{"environment"="test";"os"="[""ubuntu-latest""]";"shell"="pwsh";"buildMode"="Default"})};"fail-fast"=$false} - $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"test"=@{"EnvironmentType"="SaaS";"EnvironmentName"="test";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@()};"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@()}} + $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"test"=@{"EnvironmentType"="SaaS";"EnvironmentName"="test";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@();"unpublishOldVersions"=$false};"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@();"unpublishOldVersions"=$false}} $EnvironmentCount | Should -Be 2 . (Join-Path $scriptRoot $scriptName) -getEnvironments 'test' -type 'CD' PassGeneratedOutput $EnvironmentsMatrixJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"matrix"=@{"include"=@(@{"environment"="test";"os"="[""ubuntu-latest""]";"shell"="pwsh";"buildMode"="Default"})};"fail-fast"=$false} - $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"test"=@{"EnvironmentType"="SaaS";"EnvironmentName"="test";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@()}} + $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"test"=@{"EnvironmentType"="SaaS";"EnvironmentName"="test";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@();"unpublishOldVersions"=$false}} $EnvironmentCount | Should -Be 1 } @@ -82,7 +82,7 @@ Describe "DetermineDeploymentEnvironments Action Test" { . (Join-Path $scriptRoot $scriptName) -getEnvironments '*' -type 'CD' PassGeneratedOutput $EnvironmentsMatrixJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"matrix"=@{"include"=@(@{"environment"="another";"os"="[""ubuntu-latest""]";"shell"="pwsh";"buildMode"="Default"})};"fail-fast"=$false} - $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@()}} + $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@();"unpublishOldVersions"=$false}} $EnvironmentCount | Should -Be 1 $env:GITHUB_REF_NAME = 'branch' @@ -108,7 +108,7 @@ Describe "DetermineDeploymentEnvironments Action Test" { . (Join-Path $scriptRoot $scriptName) -getEnvironments '*' -type 'CD' PassGeneratedOutput $EnvironmentsMatrixJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"matrix"=@{"include"=@(@{"environment"="another";"os"="[""ubuntu-latest""]";"shell"="pwsh";"buildMode"="Default"})};"fail-fast"=$false} - $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@()}} + $DeploymentEnvironmentsJson | ConvertFrom-Json | ConvertTo-HashTable -recurse | Should -MatchHashtable @{"another"=@{"EnvironmentType"="SaaS";"EnvironmentName"="another";"Branches"=@();"BranchesFromPolicy"=@();"Projects"="*";"DependencyInstallMode"="install";"Scope"=$null;"syncMode"=$null;"buildMode"=$null;"continuousDeployment"=$null;"runs-on"=@("ubuntu-latest");"shell"="pwsh";"ppEnvironmentUrl"="";"companyId"="";"includeTestAppsInSandboxEnvironment"=$false;"excludeAppIds"=@();"unpublishOldVersions"=$false}} $EnvironmentCount | Should -Be 1 ($EnvironmentsMatrixJson | ConvertFrom-Json | ConvertTo-HashTable -recurse).matrix.include.environment | Should -Contain "another" diff --git a/Tests/UnpublishOldAppVersions.Test.ps1 b/Tests/UnpublishOldAppVersions.Test.ps1 new file mode 100644 index 0000000000..9220ef074b --- /dev/null +++ b/Tests/UnpublishOldAppVersions.Test.ps1 @@ -0,0 +1,207 @@ +Import-Module (Join-Path $PSScriptRoot '../Actions/Deploy/Deploy.psm1') -Force + +InModuleScope Deploy { # Allows testing of private functions + Describe "UnpublishOldAppVersions" { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + DownloadAndImportBcContainerHelper -baseFolder $([System.IO.Path]::GetTempPath()) + + $script:appId = "00000000-0000-0000-0000-000000000001" + $script:otherAppId = "00000000-0000-0000-0000-000000000002" + } + + BeforeEach { + Mock OutputDebugFunctionCall { } + Mock OutputWarning { } + Mock Write-Host { } + + # The deployed app is app 1, version 2.0.0.0 + Mock Get-AppJsonFromAppFile { + param($appFile) + if ($appFile -like "*OtherApp*") { + return @{ id = $script:otherAppId; name = "Other App"; version = "2.0.0.0" } + } + return @{ id = $script:appId; name = "App 1"; version = "2.0.0.0" } + } + + Mock Renew-BcAuthContext { + return @{ AccessToken = "test-access-token"; tenantId = "test-tenant" } + } + + # The implementation requires an installed 'Application' extension >= 25.4 to proceed. + # This shared fixture is injected into every /extensions response so version detection passes. + $script:applicationExtension = @{ id = "00000000-0000-0000-0000-0000000000AA"; displayName = "Application"; packageId = "pkg-application"; isInstalled = $true; versionMajor = 26; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + + # Default published extensions in the environment: + # - app 1 v1.0.0.0 (uninstalled, old) -> eligible for unpublish + # - app 1 v2.0.0.0 (installed, deployed) -> keep + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $true; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + Mock Invoke-RestMethod { + param($Method, $Uri) + if ($Uri -like "*/companies") { + return @{ value = @( @{ id = "company-1"; name = "CRONUS" } ) } + } + if ($Method -eq "Get" -and $Uri -like "*/extensions") { + return @{ value = @($script:applicationExtension) + $script:mockExtensions } + } + # Microsoft.NAV.unpublish POST + return $null + } + } + + It 'Unpublishes an old uninstalled version when a newer version is installed' { + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Renew-BcAuthContext -Exactly 1 + # Exactly one unpublish call, targeting the old package id + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq "Post" -and $Uri -like "*extensions(pkg-app1-v1)/Microsoft.NAV.unpublish" + } + } + + It 'Uses the automation API v2.0 endpoint with a bearer token' { + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -ParameterFilter { + $Uri -like "*/v2.0/test-env/api/microsoft/automation/v2.0/companies" -and $Headers["Authorization"] -eq "Bearer test-access-token" + } + } + + It 'Does not unpublish when only one published version exists' { + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $true; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" } + } + + It 'Does not unpublish when the deployed version is not installed' { + # The deployed version (2.0.0.0) is present but NOT installed + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $false; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" } + } + + It 'Does not unpublish installed old versions or newer versions' { + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $true; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, # installed -> keep + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $true; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, # deployed -> keep + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v3"; isInstalled = $false; versionMajor = 3; versionMinor = 0; versionBuild = 0; versionRevision = 0 } # newer -> keep + ) + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" } + } + + It 'Only unpublishes versions of the matching app id' { + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $true; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:otherAppId; displayName = "Other"; packageId = "pkg-other-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + # Only deploy App 1 - Other App's old version must not be touched + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { $Method -eq "Post" -and $Uri -like "*pkg-app1-v1*" } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" -and $Uri -like "*pkg-other*" } + } + + It 'Warns but does not throw when the unpublish call fails' { + Mock Invoke-RestMethod { + param($Method, $Uri) + if ($Uri -like "*/companies") { + return @{ value = @( @{ id = "company-1" } ) } + } + if ($Method -eq "Get" -and $Uri -like "*/extensions") { + return @{ value = @($script:applicationExtension) + $script:mockExtensions } + } + throw "unpublish failed" + } + + { UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") } | Should -Not -Throw + Assert-MockCalled OutputWarning -ParameterFilter { $message -like "*Failed to unpublish*" } + } + + It 'Warns but does not throw when the environment cannot be queried' { + Mock Invoke-RestMethod { throw "network error" } + + { UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") } | Should -Not -Throw + Assert-MockCalled OutputWarning -ParameterFilter { $message -like "*failed*" } + } + + It 'Warns when no company is found' { + Mock Invoke-RestMethod { + param($Uri) + if ($Uri -like "*/companies") { + return @{ value = @() } + } + return $null + } + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled OutputWarning -ParameterFilter { $message -like "*Could not find any company*" } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" } + } + + It 'Does nothing when no app files are provided' { + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @() + + Assert-MockCalled Renew-BcAuthContext -Times 0 + Assert-MockCalled Invoke-RestMethod -Times 0 + } + + It 'Warns and skips when the environment is older than BC 25.4' { + $script:applicationExtension = @{ id = "00000000-0000-0000-0000-0000000000AA"; displayName = "Application"; packageId = "pkg-application"; isInstalled = $true; versionMajor = 25; versionMinor = 3; versionBuild = 0; versionRevision = 0 } + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled OutputWarning -ParameterFilter { $message -like "*requires Business Central 25.4*" } + Assert-MockCalled Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq "Post" } + } + + It 'Uses the installed Application version, ignoring an uninstalled Application record' { + # An older, uninstalled Application record appears first; the installed 26.0 must be used + $script:applicationExtension = @{ id = "00000000-0000-0000-0000-0000000000AA"; displayName = "Application"; packageId = "pkg-app-old"; isInstalled = $false; versionMajor = 24; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v2"; isInstalled = $true; versionMajor = 2; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = "00000000-0000-0000-0000-0000000000AA"; displayName = "Application"; packageId = "pkg-application"; isInstalled = $true; versionMajor = 26; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled OutputWarning -Times 0 -ParameterFilter { $message -like "*requires Business Central 25.4*" } + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq "Post" -and $Uri -like "*extensions(pkg-app1-v1)/Microsoft.NAV.unpublish" + } + } + + It 'Cleans up stale versions when a newer version than the deployed artifact is installed' { + # Environment has v3 installed (newer than deployed v2), plus a stale uninstalled v1 + $script:mockExtensions = @( + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v1"; isInstalled = $false; versionMajor = 1; versionMinor = 0; versionBuild = 0; versionRevision = 0 }, + @{ id = $script:appId; displayName = "App 1"; packageId = "pkg-app1-v3"; isInstalled = $true; versionMajor = 3; versionMinor = 0; versionBuild = 0; versionRevision = 0 } + ) + + UnpublishOldAppVersions -bcAuthContext @{ tenantId = "test-tenant" } -environment "test-env" -appFiles @("App1.app") + + Assert-MockCalled Invoke-RestMethod -Exactly 1 -ParameterFilter { + $Method -eq "Post" -and $Uri -like "*extensions(pkg-app1-v1)/Microsoft.NAV.unpublish" + } + } + } +}