diff --git a/CHANGELOG.md b/CHANGELOG.md index 7727b8a..95713e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-09-30 + +### Added + +- NuGet version-range support for Chocolatey dependencies. Ranges resolve to + the highest matching package version before installation. + +### Fixed + +- Build initialization now explicitly loads the pinned Pester 5 version before + importing PowerShellBuild and uses a Pester 5-compatible test task, preventing + a newer installed Pester version from being loaded. + ## [0.5.0] - 2026-08-16 ### Added diff --git a/PSDepend/PSDepend.psd1 b/PSDepend/PSDepend.psd1 index adf4c56..e97df8a 100644 --- a/PSDepend/PSDepend.psd1 +++ b/PSDepend/PSDepend.psd1 @@ -4,7 +4,7 @@ RootModule = 'PSDepend.psm1' # Version number of this module. - ModuleVersion = '0.5.0' + ModuleVersion = '0.6.0' # ID used to uniquely identify this module GUID = '63ea9e2a-320d-43ff-a11a-4930ca03cce6' diff --git a/PSDepend/PSDependScripts/Chocolatey.ps1 b/PSDepend/PSDependScripts/Chocolatey.ps1 index cc55355..bccc40b 100644 --- a/PSDepend/PSDependScripts/Chocolatey.ps1 +++ b/PSDepend/PSDependScripts/Chocolatey.ps1 @@ -8,7 +8,9 @@ Relevant Dependency metadata: Name: The name of the package - Version: Used to identify existing installs meeting this criteria. Defaults to 'latest' + Version: Used to identify existing installs meeting this criteria. Defaults to 'latest'. + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). + A bare version remains an exact version. Ranges resolve to the highest matching version. Source: Source Uri. Defaults to https://community.chocolatey.org/api/v2/ .PARAMETER Dependency @@ -127,19 +129,24 @@ function Get-ChocoInstalledPackage { Invoke-ExternalCommand @invokeExternalCommandSplat | ConvertFrom-Csv @convertFromCsvSplat } -function Get-ChocoLatestPackage { +function Get-ChocoPackage { [CmdletBinding()] param ( [string]$Name, [string]$Source, - [Management.Automation.PSCredential]$Credential + [Management.Automation.PSCredential]$Credential, + + [switch]$AllVersions ) # 'choco search' queries remote sources on both 1.x and 2.x; 'choco list' stopped # querying remote sources in Chocolatey 2.0 and rejects URL sources (issue #187) $chocoParams = @('search', "$Name", '--limit-output', '--exact') + if ($AllVersions.IsPresent) { + $chocoParams += '--all-versions' + } if ($Source) { $chocoParams += "--source='$Source'" } @@ -230,6 +237,23 @@ if (-not $Dependency.Source -or $Source -eq '') { $Credential = $Dependency.Credential +$versionRange = $null +if ($Version -ne 'latest') { + $versionRange = ConvertFrom-VersionRange -Version $Version + if (-not $versionRange) { + Write-Error "Could not parse version [$Version] for [$Name]; expected an exact version or a valid NuGet range." + if ($PSDependAction -contains 'Test') { + return $false + } + return + } +} +$installVersion = if ($versionRange -and $versionRange.IsExact) { + $versionRange.Exact +} else { + $Version +} + if (-not (Get-Command -Name 'choco.exe' -ErrorAction SilentlyContinue)) { Write-Verbose "Chocolatey is not installed. Installing from [$ChocoInstallScriptUrl]" # download and run the Chocolatey script @@ -251,9 +275,22 @@ if (-not (Get-Command -Name 'choco.exe' -ErrorAction SilentlyContinue)) { # If this is a forced install we don't need to check anything, # just install the package version requested if ($Force.IsPresent -and $PSDependAction -contains 'Install') { + if ($versionRange -and -not $versionRange.IsExact) { + $availableVersions = (Get-ChocoPackage -Name $Name -Source $Source -Credential $Credential -AllVersions).Version + $installVersion = Resolve-VersionInRange -Candidate $availableVersions -Required $Version + if (-not $installVersion) { + Write-Error "No version of [$Name] at source [$Source] satisfies range [$Version]" + if ($PSDependAction -contains 'Test') { + return $false + } + return + } + Write-Verbose "Resolved range [$Version] to version [$installVersion] for [$Name]" + } + $params = @{ Name = $Name - Version = $Version + Version = $installVersion Source = $Source Force = $Force.IsPresent } @@ -278,7 +315,7 @@ if ($existingVersion) { } # Specific version requested, and equal to current -if ($Version -ne 'latest' -and (Test-VersionEquality -ReferenceVersion $Version -DifferenceVersion $existingVersion)) { +if ($Version -ne 'latest' -and (Test-VersionInRange -Version $existingVersion -Required $Version)) { Write-Verbose "You have the requested version [$Version] of [$Name]" if ($PSDependAction -contains 'Test') { return $true @@ -287,7 +324,8 @@ if ($Version -ne 'latest' -and (Test-VersionEquality -ReferenceVersion $Version return } -# get the latest version from the source +# Resolve a range to the highest available version. Chocolatey requires an exact +# version for --version. $repoParams = @{ Name = $Name Source = $Source @@ -296,13 +334,27 @@ if ($Credential) { $repoParams.Credential = $Credential } -Write-Verbose "Getting latest package [$Name] version from source [$Source]." -$repositoryVersion = (Get-ChocoLatestPackage @repoParams).Version -if ($repositoryVersion) { - Write-Verbose "Found package [$Name] version [$repositoryVersion] on source [$Source]." +if ($versionRange -and -not $versionRange.IsExact) { + Write-Verbose "Getting available package [$Name] versions from source [$Source]." + $availableVersions = (Get-ChocoPackage @repoParams -AllVersions).Version + $installVersion = Resolve-VersionInRange -Candidate $availableVersions -Required $Version + if (-not $installVersion) { + Write-Error "No version of [$Name] at source [$Source] satisfies range [$Version]" + if ($PSDependAction -contains 'Test') { + return $false + } + return + } + Write-Verbose "Resolved range [$Version] to version [$installVersion] for [$Name]" } else { - Write-Verbose "Package [$Name] not found on source [$Source]. Nothing more can be done." - return # cannot continue + Write-Verbose "Getting latest package [$Name] version from source [$Source]." + $repositoryVersion = (Get-ChocoPackage @repoParams).Version + if ($repositoryVersion) { + Write-Verbose "Found package [$Name] version [$repositoryVersion] on source [$Source]." + } else { + Write-Verbose "Package [$Name] not found on source [$Source]. Nothing more can be done." + return # cannot continue + } } # If the version in the remote repository is less than or equal to the version installed, then we have the latest already @@ -338,7 +390,7 @@ Write-Verbose "You do not have the version requested of [$Name]: Requested versi if ($PSDependAction -contains 'Install') { $params = @{ Name = $Name - Version = $Version + Version = $installVersion Source = $Source Force = $Force.IsPresent } diff --git a/Tests/Chocolatey.Type.Tests.ps1 b/Tests/Chocolatey.Type.Tests.ps1 index 5172891..6805d4e 100644 --- a/Tests/Chocolatey.Type.Tests.ps1 +++ b/Tests/Chocolatey.Type.Tests.ps1 @@ -70,4 +70,83 @@ Describe 'Chocolatey script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { ($Arguments -join ' ') -match "--username='feeduser'" -and ($Arguments -join ' ') -match "--password='feedpass'" } } + + Context 'NuGet version ranges' { + It 'Installs the highest available version that satisfies the range' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + Mock Invoke-ExternalCommand { + 'git|1.9.0' + 'git|2.5.0' + 'git|3.0.0' + } -ParameterFilter { $Arguments -contains '--all-versions' } + + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Arguments -contains 'upgrade' -and $Arguments -contains "--version='2.5.0'" + } + } + + It 'Resolves a range before a forced installation' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + Mock Invoke-ExternalCommand { + 'git|2.4.0' + 'git|2.8.0' + } -ParameterFilter { $Arguments -contains '--all-versions' } + + & $ScriptPath -Dependency $Dep -Force + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Arguments -contains 'upgrade' -and + $Arguments -contains "--version='2.8.0'" -and + $Arguments -contains '--force' + } + } + + It 'Passes a bracketed exact range as a concrete version' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0]' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -Force + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $Arguments -contains 'upgrade' -and $Arguments -contains "--version='2.0.0'" + } + } + + It 'Accepts an installed version that satisfies the range' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + Mock Invoke-ExternalCommand { 'git|2.4.0' } -ParameterFilter { $Arguments -contains 'list' } + + & $ScriptPath -Dependency $Dep -PSDependAction Test + } | Should -BeTrue + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 -Exactly -ParameterFilter { + $Arguments -contains 'search' + } + } + + It 'Returns false when no available version satisfies the range during testing' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + Mock Invoke-ExternalCommand { 'git|1.9.0' } -ParameterFilter { $Arguments -contains '--all-versions' } + + & $ScriptPath -Dependency $Dep -PSDependAction Test -ErrorAction SilentlyContinue + } | Should -BeFalse + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 -Exactly -ParameterFilter { + $Arguments -contains 'upgrade' + } + } + + It 'Skips installation for a malformed range' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 -Exactly -ParameterFilter { + $Arguments -contains 'upgrade' + } + } + } } diff --git a/build.ps1 b/build.ps1 index 3ec4120..aa06165 100644 --- a/build.ps1 +++ b/build.ps1 @@ -39,7 +39,12 @@ param( ) $ErrorActionPreference = 'Stop' -$psakeFile = './psakeFile.ps1' +$requirementsFile = Join-Path $PSScriptRoot 'requirements.psd1' +$psakeFile = Join-Path $PSScriptRoot 'psakeFile.ps1' + +$requirements = Import-PowerShellDataFile -Path $requirementsFile +$pesterRequirement = $requirements.Pester +$requirements.Remove('Pester') if ($Bootstrap) { if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { @@ -49,13 +54,18 @@ if ($Bootstrap) { if (-not (Get-Module -Name PSDepend -ListAvailable)) { Install-Module -Name PSDepend -Repository PSGallery -Scope CurrentUser -Force -RequiredVersion '0.3.8' } - Import-Module -Name PSDepend -Verbose:$false - Invoke-PSDepend -Path './requirements.psd1' -Install -Import -Force -WarningAction SilentlyContinue } -else { - Invoke-PSDepend -Path './requirements.psd1' -Import -Force -WarningAction SilentlyContinue + +Import-Module -Name PSDepend -Verbose:$false +if ($Bootstrap) { + Invoke-PSDepend -Path $requirementsFile -Install -Force -WarningAction SilentlyContinue } +# PowerShellBuild requires Pester without an upper bound. Load the pinned version +# first, then import the remaining dependencies without re-importing Pester. +Import-Module -Name Pester -RequiredVersion $pesterRequirement.Version +Invoke-PSDepend -InputObject $requirements -Import -Force -WarningAction SilentlyContinue + if ($PSCmdlet.ParameterSetName -eq 'Help') { Get-PSakeScriptTasks -BuildFile $psakeFile | Format-Table -Property Name, Description, Alias, DependsOn diff --git a/psakeFile.ps1 b/psakeFile.ps1 index 52d3f4c..c9a4ebc 100644 --- a/psakeFile.ps1 +++ b/psakeFile.ps1 @@ -30,6 +30,31 @@ Properties { # Skips BuildHelp (GenerateMarkdown) — doc generation is not needed in the test pipeline # and Build-PSBuildMarkdown has a Remove-Module scope bug specific to PSDepend. $PSBBuildDependency = @('StageFiles') +$PSBTestDependency = @('Pester5', 'Analyze') + +Task Pester5 -Depends $PSBBuildDependency { + $moduleManifest = Join-Path $PSBPreference.Build.ModuleOutDir "$($PSBPreference.General.ModuleName).psd1" + + Import-Module -Name $moduleManifest -Force + Push-Location -LiteralPath $PSBPreference.Test.RootDir + try { + $configuration = [PesterConfiguration]::Default + $configuration.Output.Verbosity = 'Detailed' + $configuration.Run.PassThru = $true + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputPath = $PSBPreference.Test.OutputFile + $configuration.TestResult.OutputFormat = $PSBPreference.Test.OutputFormat + + $testResult = Invoke-Pester -Configuration $configuration + if ($testResult.Result -eq 'Failed') { + throw 'One or more Pester tests failed.' + } + } + finally { + Pop-Location + Remove-Module -Name $PSBPreference.General.ModuleName -ErrorAction SilentlyContinue + } +} -Description 'Executes Pester 5 tests' # Override with: .\build.ps1 InstallLocal -Properties @{PreReleaseLabel='rc1'} Task InstallLocal -Depends StageFiles { diff --git a/requirements.psd1 b/requirements.psd1 index 266175e..b503902 100644 --- a/requirements.psd1 +++ b/requirements.psd1 @@ -5,15 +5,15 @@ 'psake' = @{ Version = '4.9.1' } - 'PowerShellBuild' = @{ - Version = '0.7.3' - } 'Pester' = @{ - Version = '6.1.0' + Version = '5.9.0' Parameters = @{ SkipPublisherCheck = $true } } + 'PowerShellBuild' = @{ + Version = '0.8.2' + } 'PSScriptAnalyzer' = @{ Version = '1.19.1' }