diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ee827..37647b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Version ranges in the `Version` field using NuGet range syntax (e.g. + `'[2.2.3,3.0)'`, `'[2.0,)'`, `'(,3.0)'`) for the `PSGalleryModule`, + `PSResourceGet`, and `PSGalleryNuget` dependency types. A bare version + (e.g. `'3.2.1'`) still means that exact version; a range installs the + highest available version that satisfies it (#65, #91). - `FileDownload` is now supported on all platforms (`windows`, `core`, `macos`, `linux`); there was no Windows-only code blocking this (#98). - `FileDownload` relative `Target` paths are now rooted against `$PWD` diff --git a/CONTEXT.md b/CONTEXT.md index 841d6b3..c3de87c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: dependency (to avoid confusion with the Dependency concept), requiremen A label on a Dependency that controls inclusion when `Invoke-PSDepend` is called with `-Tags`. _Avoid_: filter, category, label +**VersionRange**: +A constraint on which versions of a Dependency satisfy it, expressed in NuGet range syntax (e.g. `[2.2.3,3.0)`, `[2.0,)`) inside the Version field. A bare version (`3.2.1`) is not a range — it means exactly that version. +_Avoid_: version spec, version constraint, MinimumVersion/MaximumVersion + ## Relationships - A **DependencyFile** contains one or more **Dependencies** and at most one **PSDependOptions** block @@ -48,6 +52,7 @@ _Avoid_: filter, category, label - A **Dependency** may carry zero or more **Tags** - A **DependencyScript** receives a **Dependency** and a set of **PSDependAction** flags on each invocation - **Target** is a field on a **Dependency** interpreted differently by each **DependencyScript** +- A **Dependency**'s Version field carries either an exact version or a **VersionRange**; each gallery **DependencyScript** resolves a **VersionRange** to a concrete version to install ## Example dialogue diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index c6dbee7..dee4808 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -8,6 +8,7 @@ Relevant Dependency metadata: Name: The name for this module Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). A bare version (e.g. '3.2.1') still means that exact version. When a range is given, the highest available version that satisfies it is installed. Target: Used as 'Scope' for Install-Module. If this is a path, we use Save-Module with this path. On reruns, PSDepend checks existing modules first and skips reinstalling when the requested version is already present. Defaults to 'AllUsers' AddToPath: If target is used as a path, prepend that path to ENV:PSModulePath Credential: The username and password used to authenticate against the private repository @@ -90,6 +91,13 @@ } } # Install the latest version of PowerCLI, allowing for prerelease + + .EXAMPLE + @{ + BuildHelpers = '[2.0.0,3.0.0)' + } + + # Install the highest BuildHelpers version that is >= 2.0.0 and < 3.0.0 (NuGet range syntax) #> [CmdletBinding()] param( @@ -192,8 +200,14 @@ if ($Repository) { $params.Add('Repository', $Repository) } +# Exact versions map straight to RequiredVersion. Ranges have no Install-Module +# parameter, so they are resolved to a concrete version just before install. +$versionRange = $null if ($Version -and $Version -ne 'latest') { - $Params.add('RequiredVersion', $Version) + $versionRange = ConvertFrom-VersionRange -Version $Version + if ($versionRange -and $versionRange.IsExact) { + $Params.add('RequiredVersion', $versionRange.Exact) + } } if ($Credential) { @@ -230,7 +244,7 @@ if ($Existing) { Write-Verbose "Found existing module [$Name]" if ($Version -and $Version -ne 'latest') { - $matchedInstall = $Existing | Where-Object { Test-VersionEquality $Version $_.Version.ToString() } | Select-Object -First 1 + $matchedInstall = $Existing | Where-Object { Test-VersionInRange -Version $_.Version.ToString() -Required $Version } | Select-Object -First 1 if ($matchedInstall) { Write-Verbose "You have the requested version [$Version] of [$Name]" Import-PSDependModule -Name $ModuleName -Action $PSDependAction -Version $matchedInstall.Version @@ -253,25 +267,7 @@ if ($Existing) { } $GalleryVersion = Find-Module @FindModuleParams | Measure-Object -Property Version -Maximum | Select-Object -ExpandProperty Maximum - [System.Version]$parsedExistingVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedExistingSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedGallerySemanticVersion = $null - $isGalleryVersionLessEquals = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedExistingSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedGallerySemanticVersion) - ) { - $parsedGallerySemanticVersion -le $parsedExistingSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedExistingVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedExistingVersion - } - else { - $false - } + $isGalleryVersionLessEquals = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 # latest, and we have latest if ( $Version -and ($Version -eq 'latest' -or $Version -eq '') -and $isGalleryVersionLessEquals) { @@ -292,6 +288,31 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { return $False } +# Resolve a version range to the highest available version that satisfies it, +# then install that exact version (Install-Module has no native range parameter). +if ($versionRange -and -not $versionRange.IsExact -and $PSDependAction -contains 'Install') { + $resolveParams = @{ Name = $Name } + if ($Repository) { $resolveParams.Add('Repository', $Repository) } + if ($Credential) { $resolveParams.Add('Credential', $Credential) } + if ($AllowPrerelease) { $resolveParams.Add('AllowPrerelease', $AllowPrerelease) } + + $resolvedVersion = $null + foreach ($candidate in (Find-Module @resolveParams -AllVersions)) { + $candidateVersion = $candidate.Version.ToString() + if ((Test-VersionInRange -Version $candidateVersion -Required $Version) -and + ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidateVersion -DifferenceVersion $resolvedVersion) -gt 0)) { + $resolvedVersion = $candidateVersion + } + } + + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] in repository [$Repository] satisfies range [$Version]" + return + } + Write-Verbose "Resolved range [$Version] to version [$resolvedVersion] for [$Name]" + $params['RequiredVersion'] = $resolvedVersion +} + if ($PSDependAction -contains 'Install') { if ('AllUsers', 'CurrentUser' -contains $Scope) { Write-Verbose "Installing [$Name] with scope [$Scope]" diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index f9c6ece..d684392 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -10,6 +10,7 @@ Relevant Dependency metadata: Name: The name for this module Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). A bare version (e.g. '0.1.19') still means that exact version. When a range is given, the highest available version that satisfies it is installed. Source: Source Uri for Nuget. Defaults to https://www.powershellgallery.com/api/v2/ Target: Required path to save this module. No Default Example: To install PSDeploy to C:\temp\PSDeploy, I would specify C:\temp @@ -123,9 +124,9 @@ if (Test-Path $ModulePath) { $ExistingVersion = $ManifestData.ModuleVersion $GetGalleryVersion = { (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential -IsLatest).Version } - # Version string, and equal to current + # Version string (exact or range), and the installed version satisfies it if ($Version -and $Version -ne 'latest') { - if (Test-VersionEquality $Version $ExistingVersion) { + if (Test-VersionInRange -Version $ExistingVersion -Required $Version) { Write-Verbose "You have the requested version [$Version] of [$Name]" # Conditional import Import-PSDependModule -Name $ModulePath -Action $PSDependAction -Version $ExistingVersion @@ -139,25 +140,7 @@ if (Test-Path $ModulePath) { # latest, and we have latest if ($Version -and ($Version -eq 'latest' -or $Version -like '')) { $GalleryVersion = & $GetGalleryVersion - [System.Version]$parsedExistingVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedExistingSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedGallerySemanticVersion = $null - $isGalleryVersionLessEquals = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedExistingSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedGallerySemanticVersion) - ) { - $parsedGallerySemanticVersion -le $parsedExistingSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedExistingVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedExistingVersion - } - else { - $false - } + $isGalleryVersionLessEquals = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 if ($isGalleryVersionLessEquals) { Write-Verbose "You have the latest version of [$Name], with installed version [$ExistingVersion] and PSGallery version [$GalleryVersion]" @@ -190,6 +173,28 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { return $False } +# Resolve a version range to the highest available version that satisfies it; +# nuget.exe -version takes an exact version, not a range. +$installVersion = $Version +if ($Version -and $Version -notlike 'latest') { + $range = ConvertFrom-VersionRange -Version $Version + if ($range -and -not $range.IsExact) { + $resolvedVersion = $null + foreach ($candidate in (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential)) { + if ((Test-VersionInRange -Version $candidate.Version -Required $Version) -and + ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidate.Version -DifferenceVersion $resolvedVersion) -gt 0)) { + $resolvedVersion = $candidate.Version + } + } + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] at source [$Source] satisfies range [$Version]" + return + } + Write-Verbose "Resolved range [$Version] to version [$resolvedVersion] for [$Name]" + $installVersion = $resolvedVersion + } +} + if ($PSDependAction -contains 'Install') { $TargetExists = Test-Path $Target -PathType Container @@ -200,7 +205,7 @@ if ($PSDependAction -contains 'Install') { $Null = New-Item -ItemType Directory -Path $Target -Force -ErrorAction SilentlyContinue } if ($Version -and $Version -notlike 'latest') { - $NugetParams += '-version', $Version + $NugetParams += '-version', $installVersion } $NugetParams = 'install', $Name + $NugetParams @@ -209,6 +214,6 @@ if ($PSDependAction -contains 'Install') { # Conditional import $importVs = if ($Version -and $Version -notlike 'latest') { - $Version + $installVersion } Import-PSDependModule -Name $ModulePath -Action $PSDependAction -Version $importVs diff --git a/PSDepend/PSDependScripts/PSResourceGet.ps1 b/PSDepend/PSDependScripts/PSResourceGet.ps1 index 0eec9d5..89ce4d4 100644 --- a/PSDepend/PSDependScripts/PSResourceGet.ps1 +++ b/PSDepend/PSDependScripts/PSResourceGet.ps1 @@ -256,10 +256,10 @@ if ($Existing) { $FindModuleParams.Add('Prerelease', $true) } - # Version string, and that version is already installed (may not be the maximum) + # Version string (exact or range), and a satisfying version is already installed $matchedExisting = if ($Version -and $Version -ne 'latest') { $Existing | Where-Object { - Test-VersionEquality -ReferenceVersion $_.Version -DifferenceVersion $Version + Test-VersionInRange -Version $_.Version -Required $Version } | Select-Object -First 1 } if ($matchedExisting) { @@ -273,26 +273,7 @@ if ($Existing) { } $GalleryVersion = Find-PSResource @FindModuleParams | Measure-Object -Property Version -Maximum | Select-Object -ExpandProperty Maximum - # Compare using SemanticVersion first (PSResourceGet uses SemVer); fall back to System.Version - [System.Version]$parsedVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedTempSemanticVersion = $null - $existingIsUpToDate = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedTempSemanticVersion) - ) { - $parsedTempSemanticVersion -le $parsedSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedVersion - } - else { - $false - } + $existingIsUpToDate = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 # latest, and we have latest if ($Version -and ($Version -eq 'latest' -or $Version -eq '') -and $existingIsUpToDate) { diff --git a/PSDepend/Private/Compare-Version.ps1 b/PSDepend/Private/Compare-Version.ps1 new file mode 100644 index 0000000..3b0a404 --- /dev/null +++ b/PSDepend/Private/Compare-Version.ps1 @@ -0,0 +1,77 @@ +function Compare-Version { + <# + .SYNOPSIS + Order two version strings, returning -1, 0, or 1. + + .DESCRIPTION + Coerce both version strings to a common comparable type and compare them via + [IComparable]. SemanticVersion is tried first so pre-release ordering is + honoured (e.g. 1.0.0-alpha sorts below 1.0.0); System.Version is the + fallback so four-part versions (1.2.3.4) still compare. Missing System.Version + components are normalised to 0 so 1.2.3 and 1.2.3.0 compare equal. If neither + type can parse both inputs, fall back to an ordinal string comparison. + + Both operands must coerce to the same type - a SemanticVersion cannot be + compared to a System.Version - so each branch requires both inputs to parse. + + .PARAMETER ReferenceVersion + The version on the left of the comparison. + + .PARAMETER DifferenceVersion + The version on the right of the comparison. + + .EXAMPLE + Compare-Version -ReferenceVersion '1.2.0' -DifferenceVersion '1.2.3' + + Returns -1 (1.2.0 is less than 1.2.3). + + .EXAMPLE + Compare-Version -ReferenceVersion '1.0.0' -DifferenceVersion '1.0.0-beta' + + Returns 1 (a release sorts above its pre-release). + #> + [CmdletBinding()] + [OutputType([int])] + param( + [string]$ReferenceVersion, + [string]$DifferenceVersion + ) + + # SemanticVersion first: it orders pre-release labels correctly. + [System.Management.Automation.SemanticVersion]$refSemVer = $null + [System.Management.Automation.SemanticVersion]$diffSemVer = $null + if ( + [System.Management.Automation.SemanticVersion]::TryParse($ReferenceVersion, [ref]$refSemVer) -and + [System.Management.Automation.SemanticVersion]::TryParse($DifferenceVersion, [ref]$diffSemVer) + ) { + return $refSemVer.CompareTo($diffSemVer) + } + + # System.Version fallback handles four-part versions SemVer rejects. + # Normalise absent components (-1) to 0 so 1.2.3 equals 1.2.3.0. + [System.Version]$refVer = $null + [System.Version]$diffVer = $null + if ( + [System.Version]::TryParse($ReferenceVersion, [ref]$refVer) -and + [System.Version]::TryParse($DifferenceVersion, [ref]$diffVer) + ) { + $refNormalised = [System.Version]::new( + [Math]::Max($refVer.Major, 0), + [Math]::Max($refVer.Minor, 0), + [Math]::Max($refVer.Build, 0), + [Math]::Max($refVer.Revision, 0) + ) + $diffNormalised = [System.Version]::new( + [Math]::Max($diffVer.Major, 0), + [Math]::Max($diffVer.Minor, 0), + [Math]::Max($diffVer.Build, 0), + [Math]::Max($diffVer.Revision, 0) + ) + return $refNormalised.CompareTo($diffNormalised) + } + + # Neither type parses both: ordinal string comparison, clamped to -1/0/1. + return [Math]::Sign( + [string]::Compare($ReferenceVersion, $DifferenceVersion, [System.StringComparison]::OrdinalIgnoreCase) + ) +} diff --git a/PSDepend/Private/ConvertFrom-VersionRange.ps1 b/PSDepend/Private/ConvertFrom-VersionRange.ps1 new file mode 100644 index 0000000..81d7686 --- /dev/null +++ b/PSDepend/Private/ConvertFrom-VersionRange.ps1 @@ -0,0 +1,108 @@ +function ConvertFrom-VersionRange { + <# + .SYNOPSIS + Parse a NuGet version range string into a structured bounds object. + + .DESCRIPTION + PSDepend carries version ranges in the Version field using NuGet range + syntax. A string is only treated as a range when it contains a range + delimiter ('[', ']', '(', ')', ','); a bare version (e.g. 3.2.1) is returned + as an exact match so existing requirements files keep their meaning. + + Returns a [PSCustomObject] with these properties: + IsExact - $true when the request is a single exact version + Exact - the exact version string (only when IsExact) + Min - lower bound version string, or $null for no lower bound + Max - upper bound version string, or $null for no upper bound + MinInclusive - $true when the lower bound is inclusive ('[') + MaxInclusive - $true when the upper bound is inclusive (']') + + Brackets denote inclusive bounds, parentheses exclusive. A bracketed single + value ([1.0]) is an exact match. An empty side means that bound is open. + Malformed ranges produce a non-terminating error and return nothing. + + .PARAMETER Version + The version or NuGet range string to parse. + + .EXAMPLE + ConvertFrom-VersionRange -Version '3.2.1' + + Returns an object with IsExact = $true and Exact = '3.2.1'. + + .EXAMPLE + ConvertFrom-VersionRange -Version '[2.2.3,3.0)' + + Returns Min = '2.2.3' (inclusive), Max = '3.0' (exclusive). + + .EXAMPLE + ConvertFrom-VersionRange -Version '(,3.0)' + + Returns Min = $null, Max = '3.0' (exclusive) - an upper-bound-only range. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [string]$Version + ) + + $exactResult = { + param($Value) + [PSCustomObject]@{ + IsExact = $true + Exact = $Value + Min = $null + Max = $null + MinInclusive = $true + MaxInclusive = $true + } + } + + # No range delimiter: a bare version is an exact match. + if ($Version -notmatch '[\[\](),]') { + return & $exactResult $Version + } + + # Bracketed/parenthesised range: first and last char carry inclusivity. + $open = $Version[0] + $close = $Version[-1] + if ($open -notin '[', '(' -or $close -notin ']', ')') { + Write-Error "Invalid version range [$Version]: must start with '[' or '(' and end with ']' or ')'." + return + } + + $minInclusive = $open -eq '[' + $maxInclusive = $close -eq ']' + $inner = $Version.Substring(1, $Version.Length - 2) + + # A single value with no comma (e.g. [1.0]) is an exact match. + if ($inner -notmatch ',') { + $single = $inner.Trim() + if ([string]::IsNullOrEmpty($single)) { + Write-Error "Invalid version range [$Version]: no version specified." + return + } + if (-not $minInclusive -or -not $maxInclusive) { + Write-Error "Invalid version range [$Version]: a single version must be bracketed as [version]." + return + } + return & $exactResult $single + } + + $parts = $inner -split ',', 2 + $min = $parts[0].Trim() + $max = $parts[1].Trim() + + if ([string]::IsNullOrEmpty($min) -and [string]::IsNullOrEmpty($max)) { + Write-Error "Invalid version range [$Version]: at least one bound is required." + return + } + + [PSCustomObject]@{ + IsExact = $false + Exact = $null + Min = if ([string]::IsNullOrEmpty($min)) { $null } else { $min } + Max = if ([string]::IsNullOrEmpty($max)) { $null } else { $max } + MinInclusive = $minInclusive + MaxInclusive = $maxInclusive + } +} diff --git a/PSDepend/Private/Test-VersionEquality.ps1 b/PSDepend/Private/Test-VersionEquality.ps1 index 396cccd..52fe904 100644 --- a/PSDepend/Private/Test-VersionEquality.ps1 +++ b/PSDepend/Private/Test-VersionEquality.ps1 @@ -45,43 +45,7 @@ return $false } - # Parsing requires existing references to exist, so we create them. - [System.Version]$parsedRef = $null - [System.Version]$parsedDiff = $null - - # Check if we can parse both versions as System.Version. If we can, we - # compare them using individual components. - # Because System.Version treats missing components as -1, we use Math.Max to - # treat them as 0 for comparison purposes (e.g. 1.2 is treated as 1.2.0.0). - if ([System.Version]::TryParse($ReferenceVersion, [ref]$parsedRef) -and - [System.Version]::TryParse($DifferenceVersion, [ref]$parsedDiff) - ) { - return ( - $parsedRef.Major -eq $parsedDiff.Major -and - $parsedRef.Minor -eq $parsedDiff.Minor -and - [Math]::Max($parsedRef.Build, 0) -eq [Math]::Max($parsedDiff.Build, 0) -and - [Math]::Max($parsedRef.Revision, 0) -eq [Math]::Max($parsedDiff.Revision, 0) - ) - } - - # If they can't be parsed as System.Version, we attempt to parse them as - # SemanticVersion, which can handle prerelease and build metadata. - [System.Management.Automation.SemanticVersion]$parsedRefSemVer = $null - [System.Management.Automation.SemanticVersion]$parsedDiffSemVer = $null - - if ( - [System.Management.Automation.SemanticVersion]::TryParse( - $ReferenceVersion, [ref]$parsedRefSemVer - ) -and - [System.Management.Automation.SemanticVersion]::TryParse( - $DifferenceVersion, [ref]$parsedDiffSemVer - ) - ) { - return $parsedRefSemVer -eq $parsedDiffSemVer - } - - # TODO: Investigate if we want to add additional parsing logic here for - # other version formats (e.g. date or commit based versions) - - return $ReferenceVersion -eq $DifferenceVersion + # Equality is the zero case of the shared ordering primitive. Compare-Version + # handles SemanticVersion, normalised System.Version, and string fallback. + return (Compare-Version -ReferenceVersion $ReferenceVersion -DifferenceVersion $DifferenceVersion) -eq 0 } diff --git a/PSDepend/Private/Test-VersionInRange.ps1 b/PSDepend/Private/Test-VersionInRange.ps1 new file mode 100644 index 0000000..89edc70 --- /dev/null +++ b/PSDepend/Private/Test-VersionInRange.ps1 @@ -0,0 +1,73 @@ +function Test-VersionInRange { + <# + .SYNOPSIS + Test whether an installed version satisfies a requested version or range. + + .DESCRIPTION + The single entry point gallery DependencyScripts use to decide whether an + already-installed version satisfies the request. The request may be an exact + version (delegated to Test-VersionEquality) or a NuGet range (parsed by + ConvertFrom-VersionRange and compared bound-by-bound with Compare-Version). + + Range semantics live here so every DependencyType evaluates a range the same + way, regardless of what its installer accepts natively. + + .PARAMETER Version + The concrete installed version to test. + + .PARAMETER Required + The requested version or NuGet range string. + + .EXAMPLE + Test-VersionInRange -Version '2.5.0' -Required '[2.2.3,3.0)' + + Returns $true (2.5.0 falls within the range). + + .EXAMPLE + Test-VersionInRange -Version '3.0.0' -Required '[2.2.3,3.0)' + + Returns $false (the upper bound is exclusive). + + .EXAMPLE + Test-VersionInRange -Version '3.2.1' -Required '3.2.1' + + Returns $true (exact match). + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [string]$Version, + [string]$Required + ) + + if ([string]::IsNullOrEmpty($Version) -or [string]::IsNullOrEmpty($Required)) { + return $false + } + + $range = ConvertFrom-VersionRange -Version $Required -ErrorAction SilentlyContinue + if (-not $range) { + return $false + } + + if ($range.IsExact) { + return Test-VersionEquality -ReferenceVersion $Version -DifferenceVersion $range.Exact + } + + if ($null -ne $range.Min) { + $lower = Compare-Version -ReferenceVersion $Version -DifferenceVersion $range.Min + $satisfiesMin = if ($range.MinInclusive) { $lower -ge 0 } else { $lower -gt 0 } + if (-not $satisfiesMin) { + return $false + } + } + + if ($null -ne $range.Max) { + $upper = Compare-Version -ReferenceVersion $Version -DifferenceVersion $range.Max + $satisfiesMax = if ($range.MaxInclusive) { $upper -le 0 } else { $upper -lt 0 } + if (-not $satisfiesMax) { + return $false + } + } + + return $true +} diff --git a/Tests/Compare-Version.Tests.ps1 b/Tests/Compare-Version.Tests.ps1 new file mode 100644 index 0000000..4e9626e --- /dev/null +++ b/Tests/Compare-Version.Tests.ps1 @@ -0,0 +1,81 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Compare-Version' { + + Context 'SemanticVersion ordering' { + + It 'Returns 0 for equal three-part versions' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3' -DifferenceVersion '1.2.3' + } | Should -Be 0 + } + + It 'Returns -1 when reference is lower' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.0' -DifferenceVersion '1.2.3' + } | Should -Be -1 + } + + It 'Returns 1 when reference is higher' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '2.0.0' -DifferenceVersion '1.9.9' + } | Should -Be 1 + } + + It 'Orders a pre-release below its release' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.0.0-alpha' -DifferenceVersion '1.0.0' + } | Should -Be -1 + } + + It 'Orders pre-release labels lexically' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '2.0.0-beta' -DifferenceVersion '2.0.0-alpha' + } | Should -Be 1 + } + } + + Context 'System.Version fallback and normalisation' { + + It 'Compares four-part versions SemVer rejects' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3.4' -DifferenceVersion '1.2.3.5' + } | Should -Be -1 + } + + It 'Treats absent build/revision as zero' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3.0' -DifferenceVersion '1.2.3' + } | Should -Be 0 + } + + It 'Distinguishes a non-zero revision from an absent one' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '0.0.0.5' -DifferenceVersion '0.0.0' + } | Should -Be 1 + } + } + + Context 'String fallback' { + + It 'Returns 0 for identical unparseable strings' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion 'latest' -DifferenceVersion 'latest' + } | Should -Be 0 + } + + It 'Returns non-zero for different unparseable strings' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion 'latest' -DifferenceVersion 'stable' + } | Should -Not -Be 0 + } + } +} diff --git a/Tests/ConvertFrom-VersionRange.Tests.ps1 b/Tests/ConvertFrom-VersionRange.Tests.ps1 new file mode 100644 index 0000000..8e539d9 --- /dev/null +++ b/Tests/ConvertFrom-VersionRange.Tests.ps1 @@ -0,0 +1,103 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'ConvertFrom-VersionRange' { + + Context 'Exact versions (no delimiters)' { + + It 'Treats a bare version as exact' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '3.2.1' + $r.IsExact | Should -BeTrue + $r.Exact | Should -Be '3.2.1' + } + } + + It 'Treats a bracketed single version as exact' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[1.0]' + $r.IsExact | Should -BeTrue + $r.Exact | Should -Be '1.0' + } + } + } + + Context 'Bounded ranges' { + + It 'Parses an inclusive-lower, exclusive-upper range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[2.2.3,3.0)' + $r.IsExact | Should -BeFalse + $r.Min | Should -Be '2.2.3' + $r.Max | Should -Be '3.0' + $r.MinInclusive | Should -BeTrue + $r.MaxInclusive | Should -BeFalse + } + } + + It 'Parses an exclusive-lower, inclusive-upper range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '(1.0,2.0]' + $r.MinInclusive | Should -BeFalse + $r.MaxInclusive | Should -BeTrue + } + } + + It 'Tolerates whitespace around bounds' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[1.0, 2.0]' + $r.Min | Should -Be '1.0' + $r.Max | Should -Be '2.0' + } + } + } + + Context 'Open-ended ranges' { + + It 'Parses a minimum-only range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[2.0,)' + $r.Min | Should -Be '2.0' + $r.Max | Should -BeNullOrEmpty + $r.MinInclusive | Should -BeTrue + } + } + + It 'Parses a maximum-only range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '(,3.0)' + $r.Min | Should -BeNullOrEmpty + $r.Max | Should -Be '3.0' + $r.MaxInclusive | Should -BeFalse + } + } + } + + Context 'Malformed ranges' { + + It 'Errors on a missing closing bracket' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '[1.0,2.0' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + + It 'Errors on an empty range' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '(,)' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + + It 'Errors on a parenthesised single version' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '(1.0)' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index 84a5abe..8b51826 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -204,4 +204,49 @@ Describe 'PSGalleryModule script' { -ParameterFilter { -not $PSBoundParameters.ContainsKey('Repository') } } } + + Context 'Version range resolution' { + It 'Resolves a range to the highest satisfying version and installs it exactly' { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + Mock Find-Module { + @( + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.9.0' } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'3.0.0' } + ) + } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $RequiredVersion -eq '2.5.0' + } + } + + It 'Errors and skips install when no available version satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + Mock Find-Module { @([PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.0.0' }) } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } + + It 'Skips install when an installed version already satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } } -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index c970c9e..0ced47a 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -92,4 +92,39 @@ Describe 'PSGalleryNuget script' { Should -Invoke -CommandName BootStrap-Nuget -ModuleName PSDepend -Times 0 } } + + Context 'Version range resolution' { + It 'Resolves a range to the highest satisfying version and passes it to nuget install' { + InModuleScope PSDepend { + Mock Find-NugetPackage { + @( + [PSCustomObject]@{ Version = '1.9.0' } + [PSCustomObject]@{ Version = '2.5.0' } + [PSCustomObject]@{ Version = '3.0.0' } + ) + } + } + $targetDir = (New-Item 'TestDrive:/psgnuget-range' -ItemType Directory -Force).FullName + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Target $targetDir -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 1 -ParameterFilter { + $i = [array]::IndexOf($Arguments, '-version') + $i -ge 0 -and $Arguments[$i + 1] -eq '2.5.0' + } + } + + It 'Errors and skips nuget install when no version satisfies the range' { + InModuleScope PSDepend { + Mock Find-NugetPackage { @([PSCustomObject]@{ Version = '1.0.0' }) } + } + $targetDir = (New-Item 'TestDrive:/psgnuget-range-none' -ItemType Directory -Force).FullName + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Target $targetDir -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 + } + } } diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 0f94ce4..b72fbc0 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -282,4 +282,27 @@ Describe 'PSResourceGet script' { -ParameterFilter { $TrustRepository -eq $true } } } + + Context 'Version range (pass-through)' { + It 'Forwards a NuGet range straight to Install-PSResource -Version' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 1 -Exactly ` + -ParameterFilter { $Version -eq '[2.0.0,3.0.0)' } + } + + It 'Skips install when an installed version already satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } } ` + -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/Test-VersionInRange.Tests.ps1 b/Tests/Test-VersionInRange.Tests.ps1 new file mode 100644 index 0000000..66e12a6 --- /dev/null +++ b/Tests/Test-VersionInRange.Tests.ps1 @@ -0,0 +1,105 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Test-VersionInRange' { + + Context 'Null and empty inputs' { + + It 'Returns false when Version is empty' { + InModuleScope PSDepend { + Test-VersionInRange -Version '' -Required '[1.0,2.0)' + } | Should -BeFalse + } + + It 'Returns false when Required is empty' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '' + } | Should -BeFalse + } + } + + Context 'Exact requests' { + + It 'Returns true for a matching exact version' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.2.1' -Required '3.2.1' + } | Should -BeTrue + } + + It 'Returns false for a non-matching exact version' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.2.0' -Required '3.2.1' + } | Should -BeFalse + } + } + + Context 'Bounded ranges' { + + It 'Returns true inside the range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.5.0' -Required '[2.2.3,3.0)' + } | Should -BeTrue + } + + It 'Honours an inclusive lower bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.2.3' -Required '[2.2.3,3.0)' + } | Should -BeTrue + } + + It 'Honours an exclusive upper bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.0.0' -Required '[2.2.3,3.0)' + } | Should -BeFalse + } + + It 'Returns false below the range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.2.2' -Required '[2.2.3,3.0)' + } | Should -BeFalse + } + + It 'Honours an exclusive lower bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '(1.0.0,2.0.0]' + } | Should -BeFalse + } + } + + Context 'Open-ended ranges' { + + It 'Returns true at or above a minimum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '5.0.0' -Required '[2.0,)' + } | Should -BeTrue + } + + It 'Returns false below a minimum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.5.0' -Required '[2.0,)' + } | Should -BeFalse + } + + It 'Returns true below a maximum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.9.0' -Required '(,3.0)' + } | Should -BeTrue + } + } + + Context 'Malformed request' { + + It 'Returns false for a malformed range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '[1.0,2.0' + } | Should -BeFalse + } + } +} diff --git a/adr/0001-nuget-version-ranges.md b/adr/0001-nuget-version-ranges.md new file mode 100644 index 0000000..aca9e67 --- /dev/null +++ b/adr/0001-nuget-version-ranges.md @@ -0,0 +1,43 @@ +# Version ranges use NuGet range syntax, resolved to an exact version + +## Context + +Issues #65 and #91 asked for version-range support (comparison operators, and +Minimum/Maximum version). The Version field has always been a single string +holding an exact version, `latest`, or `''`. + +## Decision + +A VersionRange is expressed in **NuGet range syntax** (`[2.2.3,3.0)`, `[2.0,)`, +`(,3.0)`) carried in the existing Version field. A string is treated as a range +only when it contains a range delimiter (`[`, `]`, `(`, `)`, `,`); a bare version +(`3.2.1`) keeps its existing exact-match meaning. There is no OR support — a +range is a single contiguous interval. + +For PSResourceGet the range is passed straight to `Install-PSResource -Version`. +For PSGalleryModule and PSGalleryNuget we **resolve the range to an exact version +ourselves** (find available versions → filter with `Test-VersionInRange` → select +the maximum that satisfies → install that exact version) rather than translating +to native installer parameters. + +## Considered Options + +- **Named keys (`MinimumVersion`/`MaximumVersion`).** Most readable, but requires + adding fields to the Dependency object in `Get-Dependency.ps1` and the type — + a much larger blast radius than reusing the Version string. +- **Operator strings (`>2.2.3,<3.0`).** The comma's AND/OR meaning was ambiguous + (flagged on #65) and there is no established grammar to point users to. +- **Strict NuGet semantics** (bare `1.0` means `>= 1.0`). Rejected: it would + silently change the meaning of every existing requirements file. + +## Consequences + +- `Install-Module`'s `-MinimumVersion`/`-MaximumVersion` are **inclusive only** and + cannot express the exclusive bound in `(1.0,2.0)` or `(,3.0)`. Translating a + range to those parameters would leak excluded versions, so we deliberately + bypass them and install an exact resolved version. A future reader should not + "simplify" this back to native range parameters. +- Range semantics live in one place (`Test-VersionInRange`, built on a shared + `Compare-Version` ordering primitive), not reinterpreted per installer. +- We deviate from strict NuGet, where bare `1.0` means a minimum — here it stays + exact for backward compatibility.