diff --git a/.github/scripts/Update-GalleryModulePin.ps1 b/.github/scripts/Update-GalleryModulePin.ps1 new file mode 100644 index 0000000..cf25836 --- /dev/null +++ b/.github/scripts/Update-GalleryModulePin.ps1 @@ -0,0 +1,339 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Raise a pinned PowerShell Gallery module version to the newest release inside its allowed range. + +.DESCRIPTION + Reads the version currently pinned in a file, asks the PowerShell Gallery which versions of + the module exist, picks the highest one inside the allowed range, and rewrites the pin when + that is newer than what is there. + + The script exists because Dependabot has no PowerShell Gallery ecosystem, so nothing on the + platform will ever open the pull request that moves such a pin. See the Dependency Updates + capability for the gap and the pattern that fills it. + + Nothing about how tightly the pin is set changes here - only its movement. The identity half + of a pin (a module GUID) is deliberately untouched, because identity does not change between + versions of the same module. + + The pin is found with a regular expression carrying a 'version' capture group, so the script + does not care whether it lives in a script parameter default, a data file, or a workflow + input. Only the captured group is rewritten; every other byte of the file, including its line + endings and byte-order mark, is preserved. + + The pattern MUST match exactly once. A pattern matching nothing, or matching several places, + is an error rather than a guess - rewriting the wrong pin silently is worse than failing. + + A Gallery that cannot be reached is likewise an error, never a quiet "already up to date". + A check that reports success when it could not perform the check is the failure mode this + whole mechanism exists to remove. + +.EXAMPLE + ./Update-GalleryModulePin.ps1 -Name Pester -Path ./.github/scripts/Invoke-PesterSuite.ps1 -MinimumVersion 6.0.0 -MaximumVersion '6.*' + Raises the Pester pin to the newest 6.x release, leaving the file alone if it is already current. + +.EXAMPLE + ./Update-GalleryModulePin.ps1 -Name Pester -Path ./pins.psd1 -PinPattern "PesterVersion\s*=\s*'(?[^']+)'" + Rewrites a pin held somewhere else, by pointing the pattern at it. + +.INPUTS + None + + You can't pipe objects to Update-GalleryModulePin.ps1. + +.OUTPUTS + [pscustomobject] + + An object describing the module, the version found in the file, the newest allowed version, + the level of the change, and whether the file was rewritten. The same values are appended to + $env:GITHUB_OUTPUT when it is set, so a workflow step can branch on them. + +.LINK + https://msxorg.github.io/docs/Capabilities/dependency-updates/design/ +#> +[CmdletBinding(SupportsShouldProcess)] +[OutputType([pscustomobject])] +param( + # Module id on the Gallery, for example 'Pester'. + [Parameter(Mandatory)] + [string] $Name, + + # File holding the pinned version. + [Parameter(Mandatory)] + [string] $Path, + + # Regular expression locating the pin, carrying a 'version' capture group around the version + # itself. The default matches a '$RequiredVersion = ''' parameter default. + [Parameter()] + [string] $PinPattern = '\$RequiredVersion\s*=\s*''(?[^'']+)''', + + # Lowest version considered acceptable, inclusive. Omit for no floor. + [Parameter()] + [version] $MinimumVersion, + + # Highest version considered acceptable. Accepts a trailing wildcard in the style of a + # '#Requires -Modules' specification, so '6.*' means "any 6.x, never 7.0.0". Omit for no + # ceiling - which lets a new major be proposed, so only do that deliberately. + [Parameter()] + [string] $MaximumVersion, + + # Root of the Gallery's OData v2 feed. A real parameter rather than a test hook: it is what + # points the script at an internal mirror, and pointing it at a stub is a side effect of that. + [Parameter()] + [string] $GalleryUri = 'https://www.powershellgallery.com/api/v2', + + # Consider prerelease versions too. Off by default - a CI pin should not move onto a preview. + [Parameter()] + [switch] $AllowPrerelease +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-WorkflowAnnotation { + <# + .SYNOPSIS + Emit a GitHub Actions annotation that renders above the collapsed log. + + .DESCRIPTION + Writes a '::notice::', '::warning::', or '::error::' workflow command with the + dynamic parts percent-encoded, so a value carrying '%', a newline, a colon, or a + comma cannot corrupt or break out of the single-line command. + + .EXAMPLE + Write-WorkflowAnnotation -Type notice -Title 'Pin' -Message 'Pester is current at 6.0.1' + Renders a notice on the run summary and in the Checks view. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + param( + # Annotation severity, which decides how GitHub renders it. + [Parameter(Mandatory)] + [ValidateSet('notice', 'warning', 'error')] + [string] $Type, + + # Short headline shown in bold on the annotation. + [Parameter(Mandatory)] + [string] $Title, + + # The annotation body. + [Parameter(Mandatory)] + [string] $Message + ) + $encodedMessage = $Message -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A' + # A value in a command property needs ':' and ',' encoded too - they delimit the list. + $encodedTitle = $Title -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A' -replace ':', '%3A' -replace ',', '%2C' + Write-Output "::${Type} title=${encodedTitle}::${encodedMessage}" +} + +function Get-VersionCeiling { + <# + .SYNOPSIS + Turn a maximum-version specification into a comparable bound. + + .DESCRIPTION + Accepts either an exact version, which bounds inclusively, or a trailing-wildcard form + such as '6.*' in the style of a '#Requires -Modules' specification, which bounds + exclusively at the next value of the last fixed component. '6.*' therefore admits every + 6.x release and excludes 7.0.0. + + .EXAMPLE + Get-VersionCeiling -MaximumVersion '6.*' + Returns a bound of 7.0 that is not inclusive. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The maximum-version specification to interpret. + [Parameter(Mandatory)] + [string] $MaximumVersion + ) + if ($MaximumVersion -match '^(?\d+(\.\d+)*)\.\*$') { + $parts = [System.Collections.Generic.List[string]] ($Matches.prefix -split '\.') + $parts[$parts.Count - 1] = [string] ([int] $parts[$parts.Count - 1] + 1) + while ($parts.Count -lt 2) { $parts.Add('0') } + return [pscustomobject]@{ Bound = [version] ($parts -join '.'); Inclusive = $false } + } + return [pscustomobject]@{ Bound = [version] $MaximumVersion; Inclusive = $true } +} + +function Get-UpdateLevel { + <# + .SYNOPSIS + Describe how far a version moved, in the vocabulary the update labels use. + + .DESCRIPTION + Compares two versions and reports 'major', 'minor', or 'patch' - the level the Dependency + Updates capability labels an update pull request with, kept deliberately separate from + this repository's own release-bump labels. + + .EXAMPLE + Get-UpdateLevel -From 6.0.1 -To 6.1.0 + Returns 'minor'. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The version being replaced. + [Parameter(Mandatory)] + [version] $From, + + # The version replacing it. + [Parameter(Mandatory)] + [version] $To + ) + if ($To.Major -ne $From.Major) { return 'major' } + if ($To.Minor -ne $From.Minor) { return 'minor' } + return 'patch' +} + +function Get-GalleryVersion { + <# + .SYNOPSIS + List the versions of a module published to a PowerShell Gallery feed. + + .DESCRIPTION + Pages the OData v2 'FindPackagesById()' endpoint until it stops returning entries, + because the feed caps a page well below the number of releases a long-lived module has. + Prereleases are excluded by the query itself unless they were asked for. + + A version the feed reports in a form that is not parsable is skipped rather than fatal - + one malformed entry must not stop the pin from moving - but a feed that cannot be reached + at all throws, so a broken query can never look like "nothing newer". + + .EXAMPLE + Get-GalleryVersion -Name Pester -GalleryUri https://www.powershellgallery.com/api/v2 + Returns every published stable version of Pester. + + .OUTPUTS + [version[]] + #> + [CmdletBinding()] + [OutputType([version[]])] + param( + # Module id to look up. + [Parameter(Mandatory)] + [string] $Name, + + # Root of the OData v2 feed. + [Parameter(Mandatory)] + [string] $GalleryUri, + + # Include prerelease versions in the result. + [Parameter()] + [switch] $AllowPrerelease + ) + $found = [System.Collections.Generic.List[version]]::new() + $skip = 0 + # The feed pages; a page short of this many entries is the last one. The cap is a guard + # against an endpoint that never returns an empty page, not an expected outcome. + $maxRequests = 50 + for ($request = 0; $request -lt $maxRequests; $request++) { + $query = "FindPackagesById()?id='$Name'&`$select=Version&`$skip=$skip" + if (-not $AllowPrerelease) { + $query += "&`$filter=IsPrerelease eq false" + } + $uri = "$($GalleryUri.TrimEnd('/'))/$query" + Write-Verbose "Querying $uri" + $response = Invoke-RestMethod -Uri $uri -Headers @{ Accept = 'application/atom+xml' } -MaximumRetryCount 3 -RetryIntervalSec 5 + $entries = @($response | Where-Object { $_ -and $_.PSObject.Properties.Name -contains 'properties' }) + if ($entries.Count -eq 0) { break } + foreach ($entry in $entries) { + $parsed = [version]::new() + if ([version]::TryParse($entry.properties.Version, [ref] $parsed)) { + $found.Add($parsed) + } else { + Write-Verbose "Skipping unparsable version '$($entry.properties.Version)'." + } + } + $skip += $entries.Count + } + return $found.ToArray() +} + +$pinFile = (Resolve-Path -LiteralPath $Path).ProviderPath + +Write-Output "::group::Read the pinned $Name version from $pinFile" +# Read and write the whole file as text so line endings survive untouched, and keep the byte-order +# mark exactly as found - rewriting one version must not restyle the file around it. +$originalBytes = [System.IO.File]::ReadAllBytes($pinFile) +$hasBom = $originalBytes.Length -ge 3 -and $originalBytes[0] -eq 0xEF -and $originalBytes[1] -eq 0xBB -and $originalBytes[2] -eq 0xBF +$content = [System.IO.File]::ReadAllText($pinFile) + +$matched = [regex]::Matches($content, $PinPattern) +if ($matched.Count -eq 0) { + throw "The pin pattern '$PinPattern' matched nothing in $pinFile. The pin has moved or been reshaped; update the pattern rather than letting the check pass silently." +} +if ($matched.Count -gt 1) { + throw "The pin pattern '$PinPattern' matched $($matched.Count) places in $pinFile. Narrow it so exactly one pin is rewritten." +} +$versionGroup = $matched[0].Groups['version'] +if (-not $versionGroup.Success) { + throw "The pin pattern '$PinPattern' has no 'version' capture group, so there is nothing to rewrite." +} +$currentVersion = [version] $versionGroup.Value +Write-Output "$Name is pinned to $currentVersion." +Write-Output '::endgroup::' + +Write-Output "::group::Ask the Gallery which versions of $Name exist" +# Wrapped so a module with exactly one published version stays an array rather than unrolling to +# a bare [version], which has no Count under Set-StrictMode. +$published = @(Get-GalleryVersion -Name $Name -GalleryUri $GalleryUri -AllowPrerelease:$AllowPrerelease) +if ($published.Count -eq 0) { + throw "The Gallery at $GalleryUri reported no versions of $Name at all. Treating that as 'nothing newer' would hide a broken query, so it is a failure." +} +Write-Output "The Gallery reports $($published.Count) published version(s)." + +$ceiling = if ($PSBoundParameters.ContainsKey('MaximumVersion')) { Get-VersionCeiling -MaximumVersion $MaximumVersion } else { $null } +$allowed = @($published | Where-Object { + (-not $MinimumVersion -or $_ -ge $MinimumVersion) -and + (-not $ceiling -or ($ceiling.Inclusive ? ($_ -le $ceiling.Bound) : ($_ -lt $ceiling.Bound))) + }) +if ($allowed.Count -eq 0) { + throw "No published version of $Name falls inside the allowed range. The range and the Gallery disagree; one of them is wrong." +} +$latest = ($allowed | Sort-Object -Descending)[0] +Write-Output "The newest allowed version is $latest." +Write-Output '::endgroup::' + +$updated = $latest -gt $currentVersion +$level = if ($updated) { Get-UpdateLevel -From $currentVersion -To $latest } else { 'none' } + +if ($updated) { + $rewritten = $content.Substring(0, $versionGroup.Index) + $latest.ToString() + $content.Substring($versionGroup.Index + $versionGroup.Length) + if ($PSCmdlet.ShouldProcess($pinFile, "Raise the $Name pin from $currentVersion to $latest")) { + [System.IO.File]::WriteAllText($pinFile, $rewritten, [System.Text.UTF8Encoding]::new($hasBom)) + Write-WorkflowAnnotation -Type notice -Title 'Dependency' -Message "$Name moves from $currentVersion to $latest ($level)." + } +} else { + Write-WorkflowAnnotation -Type notice -Title 'Dependency' -Message "$Name is current at $currentVersion; nothing to do." +} + +if ($env:GITHUB_OUTPUT) { + @( + "module=$Name" + "updated=$($updated.ToString().ToLowerInvariant())" + "current=$currentVersion" + "latest=$latest" + "level=$level" + ) | Out-File -LiteralPath $env:GITHUB_OUTPUT -Encoding utf8 -Append +} + +[pscustomobject]@{ + Module = $Name + Path = $pinFile + CurrentVersion = $currentVersion + LatestVersion = $latest + Level = $level + Updated = $updated +} diff --git a/.github/workflows/Update-ModulePin.yml b/.github/workflows/Update-ModulePin.yml new file mode 100644 index 0000000..e18103f --- /dev/null +++ b/.github/workflows/Update-ModulePin.yml @@ -0,0 +1,139 @@ +name: Update module pins + +on: + # The Gallery is checked on a schedule, offset from Dependabot's day so the two + # streams of update pull requests do not arrive together. + schedule: + - cron: "17 6 * * 2" + workflow_dispatch: + # The schedule is not enough on its own. GitHub disables a scheduled workflow + # automatically "when no repository activity has occurred in 60 days" in a public + # repository, and it does so silently - a workflow that stopped running looks + # exactly like one that ran and found nothing to do. This trigger does not cover + # the quiet window (no pushes happen then either); what it guarantees is that the + # first push after a quiet period re-checks the pin, which is when a stale pin + # starts to matter again. See the Dependency Updates capability design. + push: + branches: + - main + +concurrency: + # One update run at a time, so two runs cannot race to open the same pull request. + # Never cancelled in progress: a cancelled run could leave a branch pushed with no + # pull request opened for it. + group: ${{ github.workflow }} + cancel-in-progress: false + +# Default-deny floor: the job below grants only the scopes its steps need, so a job +# added later inherits nothing and fails closed. See the GitHub Actions coding +# standard, "Grant least-privilege permissions". +permissions: {} + +jobs: + pester: + name: Pester pin + runs-on: ubuntu-24.04 + permissions: + contents: write # push the update branch + pull-requests: write # open the update pull request + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Credentials are not persisted into .git/config, so no later step in this + # job can read the token from there. The one push that needs them supplies + # them explicitly instead. + persist-credentials: false + + - name: Check the Gallery for a newer Pester + id: pin + shell: pwsh + # The range mirrors what the suites' own '#Requires' lines declare, so the + # updater can never propose a version the suites refuse to run under. The + # identity half of the pin - the module GUID - is untouched, because it does + # not change between versions of the same module. + run: | + ./.github/scripts/Update-GalleryModulePin.ps1 ` + -Name Pester ` + -Path ./.github/scripts/Invoke-PesterSuite.ps1 ` + -MinimumVersion 6.0.0 ` + -MaximumVersion '6.*' + + - name: Open the update pull request + if: steps.pin.outputs.updated == 'true' + env: + GH_TOKEN: ${{ github.token }} + MODULE: ${{ steps.pin.outputs.module }} + CURRENT: ${{ steps.pin.outputs.current }} + LATEST: ${{ steps.pin.outputs.latest }} + LEVEL: ${{ steps.pin.outputs.level }} + shell: bash + # Every value is read from the environment rather than expanded inline, so + # nothing carried in a step output can be read as shell syntax. See the + # GitHub Actions coding standard, "Never expand untrusted input inline". + run: | + set -euo pipefail + + module_slug=$(echo "$MODULE" | tr '[:upper:]' '[:lower:]') + branch="maintenance/pin-${module_slug}-${LATEST}" + + # 'all', not 'open': a reviewer who closes an update pull request is declining + # that version, and a bot that reopens it next week has not listened. The guard + # is per-version because the branch name carries the version, so declining + # 6.1.0 never suppresses 6.1.1. + seen_count=$(gh pr list --state all --head "$branch" --json number --jq 'length') + if [ "$seen_count" -ne 0 ]; then + echo "A pull request for ${MODULE} ${LATEST} has already been raised. Nothing to do." + exit 0 + fi + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" >/dev/null 2>&1; then + echo "Branch ${branch} already exists on the remote. Nothing to do." + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git switch --create "$branch" + git add ./.github/scripts/Invoke-PesterSuite.ps1 + git commit \ + --message "Raise the pinned ${MODULE} version from ${CURRENT} to ${LATEST}" \ + --message "The Gallery published ${LATEST}, the newest release inside the range the test suites declare. The pin stays exact and the module GUID is unchanged; only the version moves." + # The token is supplied to this one command rather than persisted at checkout, + # so it never sits in .git/config for the rest of the job. Actions masks it in + # the log, and no 'set -x' is enabled here that would echo the expanded URL. + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${branch}" + + # Built in a file rather than passed inline, so the body's Markdown is not at + # the mercy of this block's indentation. + body=$(mktemp) + { + echo "The test suites now run against ${MODULE} ${LATEST}, up from ${CURRENT}." + echo + echo "The pin stays exact and verified by module GUID; only the version it points at moves. The Test check runs the full suite against the new version, so a release that breaks it shows up here rather than after merge." + echo + echo "---" + echo "
" + echo "Technical details" + echo + echo "- Opened by .github/workflows/Update-ModulePin.yml, because Dependabot has no PowerShell Gallery ecosystem to do it." + echo "- A ${LEVEL}-level change, selected as the newest version inside the range the suites' own module requirements declare." + echo "- Identity-plus-exact pins are never auto-merged, so this waits for a human review by design." + echo + echo "
" + echo + echo "
" + echo "Related issues" + echo + echo "- MSXOrg/docs#136" + echo + echo "
" + } > "$body" + + gh pr create \ + --head "$branch" \ + --title "⚙️ [Maintenance]: ${MODULE} test framework updated to ${LATEST}" \ + --body-file "$body" \ + --label dependencies \ + --label powershell \ + --label "update:${LEVEL}" \ + --label Maintenance diff --git a/src/docs/Capabilities/dependency-updates/design.md b/src/docs/Capabilities/dependency-updates/design.md index a4a7544..912bdd9 100644 --- a/src/docs/Capabilities/dependency-updates/design.md +++ b/src/docs/Capabilities/dependency-updates/design.md @@ -100,6 +100,104 @@ and the same release path as any other update. | Auto-merge policy | branch protection / auto-merge automation | | Security updates | repository security settings (on by default) | +## Ecosystems the platform updater does not cover + +Dependabot supports a fixed list of package ecosystems, and the **PowerShell +Gallery is not on it** — there is no `package-ecosystem` value for it, and +Renovate has no Gallery datasource either. This is a gap in the platform, not a +choice this organization made. A repository that pins a Gallery module in CI +therefore gets no update pull request from the updater above, however correctly +it is configured. + +That matters because of what [Dependencies](../../Coding-Standards/Dependencies.md#the-balance) +requires: a CI pipeline pins identity plus exact, and *"tight pinning is safe +**because** the updates are automated."* Without automation the same pin becomes +the "too tight" failure — the module keeps shipping fixes the repository never +takes, and the pin that made the build reproducible is what stops it being +patched. + +**If Dependabot ever ships a PowerShell Gallery ecosystem, delete this and add a +`package-ecosystem` entry.** The pattern below exists only because the platform +has no answer, and it should not outlive that. + +### The pattern + +A scheduled workflow that queries the Gallery, rewrites the pin, and opens the +same kind of labelled pull request the updater would: + +```mermaid +flowchart TD + trigger["Schedule · manual dispatch · push to the default branch"] --> query["Query the Gallery for the newest version inside the allowed range"] + query --> compare{"Newer than the pin?"} + compare -->|"no"| quiet["Do nothing"] + compare -->|"yes"| existing{"Pull request already open?"} + existing -->|"yes"| quiet + existing -->|"no"| pr["Rewrite the pin, open a labelled pull request"] + pr --> ci["Required checks run — the suite runs against the new version"] + ci --> review["Human review — identity + exact is never auto-merged"] +``` + +Two properties make it trustworthy rather than merely present: + +- **It cannot pass without performing the check.** An unreachable Gallery, a pin + pattern that matches nothing, and a pattern that matches several places are all + hard failures. Reporting "already up to date" because the lookup broke is the + exact failure this capability exists to remove. +- **It rewrites only the version.** The identity half of the pin — the module + `GUID` — is never touched, because identity does not change between versions of + the same module. The consuming script still verifies it at runtime, so an + identity mismatch fails the test check. + +### Declining a version + +The branch name carries the version, and the check for an existing pull request +looks at **every** state rather than only open ones. So **closing an update pull +request is how a reviewer declines that version**: it is never reopened, and the +run after a merge — which the default branch's `push` trigger fires with the pin +already current — finds nothing to do. + +The suppression is per version, not blanket. Declining `6.1.0` says nothing about +`6.1.1`, which arrives as its own branch and its own pull request. + +### The schedule can lapse, silently + +GitHub disables scheduled workflows automatically: *"In a public repository, +scheduled workflows are automatically disabled when no repository activity has +occurred in 60 days"* +([GitHub docs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows)). +Nothing announces it. **A scheduled workflow that has stopped running looks +exactly like one that ran and found nothing to do** — both are silent. + +This is named rather than solved, because a watchdog's own liveness would then +need watching: + +- **The evidence is the workflow's run history.** A live updater shows recent + runs that found nothing; a lapsed one shows no runs since a date. That is the + one place the two states are distinguishable. Re-enabling is a single action on + the workflow's page. +- **Recovery is automatic, detection is not.** Adding the default branch's `push` + event as a second trigger does *not* cover the quiet window — during inactivity + there are no pushes either. What it guarantees is that the **first push after a + quiet period re-checks the pin**, which is when a stale pin starts to matter + again, without anyone remembering that the schedule died. + +### Adopting it in another repository + +1. Copy the updater script and its workflow. +2. Point the script at the pin: the module name, the file holding it, and a + pattern with a `version` capture group. The pattern is why the pin can stay + wherever it already lives — a script parameter default, a data file, a + workflow input — instead of being moved into a manifest to suit the tooling. +3. Set the allowed range to whatever the consuming code already declares, so the + updater can never propose a version that code refuses to run under. +4. Ensure the `dependencies`, ecosystem, and `update:*` labels exist in the + repository; the workflow applies them and label creation is not automatic. +5. Decide the token. The default workflow token is enough, but a pull request it + opens has its checks held in an approval-required state until someone with + write access starts them. That is acceptable for an identity-plus-exact pin, + which is never auto-merged anyway; a GitHub App installation token removes the + step where it matters. + ## Where this connects - [Spec](spec.md) — the requirements this design delivers. diff --git a/tests/Update-GalleryModulePin.Tests.ps1 b/tests/Update-GalleryModulePin.Tests.ps1 new file mode 100644 index 0000000..89447d0 --- /dev/null +++ b/tests/Update-GalleryModulePin.Tests.ps1 @@ -0,0 +1,363 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +<# + .SYNOPSIS + Prove that the Gallery pin updater raises a pin when it should and leaves it alone otherwise. + + .DESCRIPTION + Update-GalleryModulePin.ps1 exists to open a pull request when a newer module version is + published and to do nothing when one is not, so those two outcomes are what these tests + assert - directly, and without waiting for the real Pester to release. + + The Gallery is served from an in-process HttpListener bound to a loopback port, driven from a + background runspace. That keeps every case deterministic and runnable offline, and it exercises + the script's real HTTP path rather than mocking it away. The '-GalleryUri' parameter is the + seam; it is a real parameter that points the script at a Gallery mirror, and pointing it at a + stub is a side effect of that rather than a reason for it. +#> + +Describe 'Update-GalleryModulePin' { + BeforeAll { + $script:sourceScript = Join-Path $PSScriptRoot '../.github/scripts/Update-GalleryModulePin.ps1' + + # Serves the OData v2 shape the script reads: an Atom feed whose entries carry a + # 'm:properties/d:Version'. Honours '$skip' so paging is real, and '$filter' so the + # prerelease exclusion is proven to be sent rather than assumed. + $script:serve = { + param($Listener, $Version, $PageSize) + + while ($Listener.IsListening) { + try { + $context = $Listener.GetContext() + } catch { + break + } + try { + $query = $context.Request.QueryString + $skip = 0 + if ($query['$skip']) { $skip = [int] $query['$skip'] } + + $selected = @($Version) + if ([string] $query['$filter'] -like '*IsPrerelease eq false*') { + $selected = @($selected | Where-Object { $_ -notmatch '-' }) + } + $page = @($selected | Select-Object -Skip $skip | Select-Object -First $PageSize) + + $builder = [System.Text.StringBuilder]::new() + $null = $builder.Append('') + $null = $builder.Append('') + foreach ($entry in $page) { + $null = $builder.Append('Stub') + $null = $builder.Append("$entry") + } + $null = $builder.Append('') + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($builder.ToString()) + $context.Response.StatusCode = 200 + $context.Response.ContentType = 'application/atom+xml;charset=utf-8' + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } catch { + # A stub failure must not kill the server and hang every remaining test. + try { $context.Response.StatusCode = 500 } catch { $null = $_ } + } finally { + try { $context.Response.OutputStream.Close() } catch { $null = $_ } + } + } + } + + function Start-GalleryStub { + <# + .SYNOPSIS + Serve a canned set of module versions over loopback HTTP. + + .DESCRIPTION + Bind an HttpListener to a free loopback port and answer the script's feed queries + from the supplied version list, paging at the requested size. 'localhost' is used + deliberately: that prefix binds without elevation, where a '+' or hostname prefix + does not. + + .EXAMPLE + Start-GalleryStub -Version '6.0.0', '6.0.1' + Returns the stub's base URI and the handles needed to stop it. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # Versions the stub reports as published, newest order irrelevant. + [Parameter(Mandatory)] + [string[]] $Version, + + # How many entries a single feed page returns, so paging can be exercised. + [Parameter()] + [int] $PageSize = 100 + ) + + $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $port = $probe.LocalEndpoint.Port + $probe.Stop() + + if (-not $PSCmdlet.ShouldProcess("http://localhost:$port/", 'Start Gallery stub')) { + return + } + + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://localhost:$port/") + $listener.Start() + + $shell = [powershell]::Create() + $shell.Runspace = [runspacefactory]::CreateRunspace() + $shell.Runspace.Open() + $null = $shell.AddScript($script:serve).AddArgument($listener).AddArgument($Version).AddArgument($PageSize) + $null = $shell.BeginInvoke() + + return [pscustomobject]@{ + Uri = "http://localhost:$port" + Listener = $listener + Shell = $shell + } + } + + function Stop-GalleryStub { + <# + .SYNOPSIS + Tear down a stub started by Start-GalleryStub. + + .DESCRIPTION + Stop the listener, which makes the blocked GetContext() throw and ends the serving + loop, then dispose the runspace and the shell driving it. + + .EXAMPLE + Stop-GalleryStub -Stub $stub + Releases the port and the background runspace. + + .OUTPUTS + None + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The object returned by Start-GalleryStub. + [Parameter(Mandatory)] + [psobject] $Stub + ) + if (-not $PSCmdlet.ShouldProcess($Stub.Uri, 'Stop Gallery stub')) { + return + } + try { $Stub.Listener.Stop() } catch { $null = $_ } + try { $Stub.Listener.Close() } catch { $null = $_ } + try { $Stub.Shell.Runspace.Dispose() } catch { $null = $_ } + try { $Stub.Shell.Dispose() } catch { $null = $_ } + } + + function New-PinFixture { + <# + .SYNOPSIS + Write a throwaway file carrying a pinned version and a module GUID. + + .DESCRIPTION + Mirror the shape of the real pin in Invoke-PesterSuite.ps1 - a '$RequiredVersion' + parameter default alongside a '$ModuleGuid' identity pin - and write it with a + byte-order mark and CRLF endings, so the tests can prove the rewrite preserves + both instead of restyling the file around the version. + + .EXAMPLE + New-PinFixture -Version 6.0.1 + Returns the path of the fixture file. + + .OUTPUTS + [string] + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The version the fixture starts out pinned to. + [Parameter(Mandatory)] + [string] $Version + ) + + $path = Join-Path ([System.IO.Path]::GetTempPath()) "pin-$([guid]::NewGuid().ToString('N')).ps1" + if (-not $PSCmdlet.ShouldProcess($path, 'Create pin fixture')) { + return + } + $lines = @( + 'param(' + " [string] `$RequiredVersion = '$Version'," + " [guid] `$ModuleGuid = 'a699dea5-2c73-4616-a270-1f7abb777e71'" + ')' + ) + [System.IO.File]::WriteAllText($path, ($lines -join "`r`n"), [System.Text.UTF8Encoding]::new($true)) + return $path + } + } + + Context 'When a newer version exists' { + It 'Raises the pin and reports the update' { + $stub = Start-GalleryStub -Version '6.0.0', '6.0.1', '6.0.2' + $pin = New-PinFixture -Version 6.0.1 + try { + # The script logs to stdout as well as returning its result, so the pipeline + # carries workflow-command strings ahead of the object. Take the object rather + # than relying on member enumeration over the whole array. + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + + $result.Updated | Should -BeTrue + $result.CurrentVersion | Should -Be ([version] '6.0.1') + $result.LatestVersion | Should -Be ([version] '6.0.2') + $result.Level | Should -Be 'patch' + [System.IO.File]::ReadAllText($pin) | Should -Match "RequiredVersion = '6\.0\.2'" + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Changes the version and nothing else — identity, byte-order mark, and line endings survive' { + $stub = Start-GalleryStub -Version '6.0.1', '6.1.0' + $pin = New-PinFixture -Version 6.0.1 + try { + $before = [System.IO.File]::ReadAllText($pin) + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + $after = [System.IO.File]::ReadAllText($pin) + $bytes = [System.IO.File]::ReadAllBytes($pin) + + $result.Level | Should -Be 'minor' + # The only textual difference is the version itself. + $after | Should -Be ($before -replace "6\.0\.1", '6.1.0') + $after | Should -Match "ModuleGuid = 'a699dea5-2c73-4616-a270-1f7abb777e71'" + $after | Should -Match "`r`n" + $bytes[0..2] | Should -Be @(0xEF, 0xBB, 0xBF) + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Writes the outputs a workflow step branches on' { + $stub = Start-GalleryStub -Version '6.0.1', '6.0.2' + $pin = New-PinFixture -Version 6.0.1 + $outputFile = Join-Path ([System.IO.Path]::GetTempPath()) "out-$([guid]::NewGuid().ToString('N')).txt" + try { + $env:GITHUB_OUTPUT = $outputFile + $null = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + + $written = [System.IO.File]::ReadAllText($outputFile) + $written | Should -Match 'updated=true' + $written | Should -Match 'latest=6\.0\.2' + $written | Should -Match 'level=patch' + } finally { + Remove-Item -Path Env:\GITHUB_OUTPUT -ErrorAction SilentlyContinue + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $outputFile -Force -ErrorAction SilentlyContinue + } + } + } + + Context 'When nothing newer is available' { + It 'Leaves the file byte-identical and reports no update' { + $stub = Start-GalleryStub -Version '6.0.0', '6.0.1' + $pin = New-PinFixture -Version 6.0.1 + try { + $before = [System.IO.File]::ReadAllBytes($pin) + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + $after = [System.IO.File]::ReadAllBytes($pin) + + $result.Updated | Should -BeFalse + $result.Level | Should -Be 'none' + $after | Should -Be $before + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Does not cross the ceiling the test suites declare' { + $stub = Start-GalleryStub -Version '6.0.1', '7.0.0' + $pin = New-PinFixture -Version 6.0.1 + try { + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + + $result.Updated | Should -BeFalse + $result.LatestVersion | Should -Be ([version] '6.0.1') + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Does not move a CI pin onto a prerelease' { + $stub = Start-GalleryStub -Version '6.0.1', '6.1.0-alpha2' + $pin = New-PinFixture -Version 6.0.1 + try { + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + + $result.Updated | Should -BeFalse + $result.LatestVersion | Should -Be ([version] '6.0.1') + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + } + + Context 'Feed paging' { + It 'Finds the newest version even when it is on a later page' { + $stub = Start-GalleryStub -Version '6.0.0', '6.0.1', '6.0.2', '6.1.0' -PageSize 2 + $pin = New-PinFixture -Version 6.0.1 + try { + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri | Select-Object -Last 1 + + $result.Updated | Should -BeTrue + $result.LatestVersion | Should -Be ([version] '6.1.0') + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + } + + Context 'When the check cannot be performed, it fails instead of passing' { + It 'Refuses to guess when the pin pattern matches nothing' { + $stub = Start-GalleryStub -Version '6.0.1', '6.0.2' + $pin = New-PinFixture -Version 6.0.1 + try { + { + & $script:sourceScript -Name Pester -Path $pin -PinPattern "NotThePin\s*=\s*'(?[^']+)'" -GalleryUri $stub.Uri + } | Should -Throw '*matched nothing*' + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Refuses to guess when the pin pattern matches more than one place' { + $stub = Start-GalleryStub -Version '6.0.1', '6.0.2' + $pin = New-PinFixture -Version 6.0.1 + try { + { + & $script:sourceScript -Name Pester -Path $pin -PinPattern "'(?[^']+)'" -GalleryUri $stub.Uri + } | Should -Throw '*matched 2 places*' + } finally { + Stop-GalleryStub -Stub $stub + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + + It 'Treats an unreachable Gallery as a failure, never as "already up to date"' { + $stub = Start-GalleryStub -Version '6.0.1' + $deadUri = $stub.Uri + Stop-GalleryStub -Stub $stub + $pin = New-PinFixture -Version 6.0.1 + try { + $before = [System.IO.File]::ReadAllBytes($pin) + { & $script:sourceScript -Name Pester -Path $pin -GalleryUri $deadUri } | Should -Throw + [System.IO.File]::ReadAllBytes($pin) | Should -Be $before + } finally { + Remove-Item -LiteralPath $pin -Force -ErrorAction SilentlyContinue + } + } + } +}