From 2c70378f24f60ae15e53c60bff531d3d6093f835 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:01:22 +0200 Subject: [PATCH 1/8] Add a script that raises a pinned PowerShell Gallery module version Dependabot has no PowerShell Gallery ecosystem, so nothing on the platform moves a pin like the Pester one in Invoke-PesterSuite.ps1. The script reads the pinned version through a caller-supplied pattern, asks the Gallery which versions exist, and rewrites the pin to the newest one inside the allowed range. The pattern must match exactly once and an unreachable Gallery is an error, so a broken lookup can never be mistaken for 'already up to date'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Update-GalleryModulePin.ps1 | 337 ++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 .github/scripts/Update-GalleryModulePin.ps1 diff --git a/.github/scripts/Update-GalleryModulePin.ps1 b/.github/scripts/Update-GalleryModulePin.ps1 new file mode 100644 index 0000000..408ce22 --- /dev/null +++ b/.github/scripts/Update-GalleryModulePin.ps1 @@ -0,0 +1,337 @@ +#!/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" +$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 +} From 91a5db73e92712310bdcf34fb93c3b06d17fe4cb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:04:23 +0200 Subject: [PATCH 2/8] Prove the pin updater fires when a newer version exists and stays quiet otherwise The Gallery is served from an in-process HttpListener on a loopback port, so both outcomes are demonstrated without waiting for Pester to release and without network access. Also fixes a bug the suite found: a feed reporting exactly one version unrolled to a bare [version], which has no Count under Set-StrictMode, so a single-version module would have crashed the check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Update-GalleryModulePin.ps1 | 4 +- tests/Update-GalleryModulePin.Tests.ps1 | 353 ++++++++++++++++++++ 2 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 tests/Update-GalleryModulePin.Tests.ps1 diff --git a/.github/scripts/Update-GalleryModulePin.ps1 b/.github/scripts/Update-GalleryModulePin.ps1 index 408ce22..e6df417 100644 --- a/.github/scripts/Update-GalleryModulePin.ps1 +++ b/.github/scripts/Update-GalleryModulePin.ps1 @@ -286,7 +286,9 @@ Write-Output "$Name is pinned to $currentVersion." Write-Output '::endgroup::' Write-Output "::group::Ask the Gallery which versions of $Name exist" -$published = Get-GalleryVersion -Name $Name -GalleryUri $GalleryUri -AllowPrerelease:$AllowPrerelease +# 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." } diff --git a/tests/Update-GalleryModulePin.Tests.ps1 b/tests/Update-GalleryModulePin.Tests.ps1 new file mode 100644 index 0000000..5b311a2 --- /dev/null +++ b/tests/Update-GalleryModulePin.Tests.ps1 @@ -0,0 +1,353 @@ +#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()] + 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() + + $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()] + param( + # The object returned by Start-GalleryStub. + [Parameter(Mandatory)] + [psobject] $Stub + ) + 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 { + $result = & $script:sourceScript -Name Pester -Path $pin -MinimumVersion 6.0.0 -MaximumVersion '6.*' -GalleryUri $stub.Uri 6>$null + + $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 6>$null + $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 6>$null + + $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 6>$null + $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 6>$null + + $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 6>$null + + $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 6>$null + + $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 + } + } + } +} From 790298e7a0d085ec569791a37811f5377e31cce9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:07:20 +0200 Subject: [PATCH 3/8] Open a pull request when a newer pinned Pester version is published Runs on a schedule, on demand, and on every push to main. The push trigger is there because GitHub silently disables a scheduled workflow after 60 days of inactivity in a public repository: it does not cover the quiet window, but it guarantees the first push after one re-checks the pin. The pull request is labelled and left for a human, because identity-plus-exact pins are the one track the Dependencies standard says is never auto-merged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Update-ModulePin.yml | 128 +++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/Update-ModulePin.yml diff --git a/.github/workflows/Update-ModulePin.yml b/.github/workflows/Update-ModulePin.yml new file mode 100644 index 0000000..14208f2 --- /dev/null +++ b/.github/workflows/Update-ModulePin.yml @@ -0,0 +1,128 @@ +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: + # Credentials are deliberately persisted here, unlike the read-only jobs in + # Docs.yml: this workflow pushes the update branch it creates. + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - 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}" + + open_count=$(gh pr list --state open --head "$branch" --json number --jq 'length') + if [ "$open_count" -ne 0 ]; then + echo "A pull request for ${MODULE} ${LATEST} is already open. Nothing to do." + exit 0 + fi + if git ls-remote --exit-code --heads origin "$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." + git push --set-upstream origin "$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 \ + --title "⚙️ [Maintenance]: ${MODULE} test framework updated to ${LATEST}" \ + --body-file "$body" \ + --label dependencies \ + --label powershell \ + --label "update:${LEVEL}" \ + --label Maintenance From 5ecf2bab899b6451ff83f090a7a7f3c44fbbbb4a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:16:48 +0200 Subject: [PATCH 4/8] Document the PowerShell Gallery gap and the updater that fills it Records that the gap is Dependabot having no PowerShell Gallery ecosystem rather than a choice made here, so if the platform ever ships one the right response is to delete this and add a package-ecosystem entry. Names the risk that a scheduled workflow is disabled silently after 60 days of inactivity in a public repository, and what distinguishes that from a run that found nothing to do. Also settles the analyzer findings on the new files and stops the new tests depending on member enumeration, which broke them under the full suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Update-GalleryModulePin.ps1 | 10 +-- .../Capabilities/dependency-updates/design.md | 87 +++++++++++++++++++ tests/Update-GalleryModulePin.Tests.ps1 | 34 +++++--- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/.github/scripts/Update-GalleryModulePin.ps1 b/.github/scripts/Update-GalleryModulePin.ps1 index e6df417..cf25836 100644 --- a/.github/scripts/Update-GalleryModulePin.ps1 +++ b/.github/scripts/Update-GalleryModulePin.ps1 @@ -330,10 +330,10 @@ if ($env:GITHUB_OUTPUT) { } [pscustomobject]@{ - Module = $Name - Path = $pinFile + Module = $Name + Path = $pinFile CurrentVersion = $currentVersion - LatestVersion = $latest - Level = $level - Updated = $updated + LatestVersion = $latest + Level = $level + Updated = $updated } diff --git a/src/docs/Capabilities/dependency-updates/design.md b/src/docs/Capabilities/dependency-updates/design.md index a4a7544..a01e6a2 100644 --- a/src/docs/Capabilities/dependency-updates/design.md +++ b/src/docs/Capabilities/dependency-updates/design.md @@ -100,6 +100,93 @@ 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 run time, so an + identity mismatch fails the test check. + +### 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 index 5b311a2..89447d0 100644 --- a/tests/Update-GalleryModulePin.Tests.ps1 +++ b/tests/Update-GalleryModulePin.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } <# .SYNOPSIS @@ -85,7 +85,7 @@ Describe 'Update-GalleryModulePin' { .OUTPUTS [pscustomobject] #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] param( # Versions the stub reports as published, newest order irrelevant. [Parameter(Mandatory)] @@ -101,6 +101,10 @@ Describe 'Update-GalleryModulePin' { $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() @@ -112,9 +116,9 @@ Describe 'Update-GalleryModulePin' { $null = $shell.BeginInvoke() return [pscustomobject]@{ - Uri = "http://localhost:$port" + Uri = "http://localhost:$port" Listener = $listener - Shell = $shell + Shell = $shell } } @@ -134,12 +138,15 @@ Describe 'Update-GalleryModulePin' { .OUTPUTS None #> - [CmdletBinding()] + [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 = $_ } @@ -191,7 +198,10 @@ Describe 'Update-GalleryModulePin' { $stub = Start-GalleryStub -Version '6.0.0', '6.0.1', '6.0.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 6>$null + # 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') @@ -209,7 +219,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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) @@ -231,7 +241,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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' @@ -252,7 +262,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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 @@ -268,7 +278,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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') @@ -282,7 +292,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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') @@ -298,7 +308,7 @@ Describe 'Update-GalleryModulePin' { $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 6>$null + $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') From 71007feffd83ad3b840c636741f7b4f1203aed1f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:16:59 +0200 Subject: [PATCH 5/8] TEMPORARY: pin Pester to 5.7.1 to prove a breaking bump fails the Test check Reverted in the next commit. 5.7.1 is outside the range the suites' #Requires lines declare, which is what a bad bump would look like. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Invoke-PesterSuite.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 index dd8f903..0c35368 100644 --- a/.github/scripts/Invoke-PesterSuite.ps1 +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -51,7 +51,7 @@ param( # Exact Pester version to run with, so a new release cannot turn an untouched pull # request red. Keep it inside the range the suites' '#Requires' lines declare. [Parameter()] - [string] $RequiredVersion = '6.0.1', + [string] $RequiredVersion = '5.7.1', # Pester's module GUID — the identity half of the pin, checked after import so a # name-squatted module cannot satisfy the version. From c6ea08ed66567b3952ff78811e32aac296562a8e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:20:07 +0200 Subject: [PATCH 6/8] Restore the Pester pin, and stop persisting checkout credentials Reverts the temporary 5.7.1 pin now that run 31266574992 has shown a breaking bump fails the Test check. The updater's checkout no longer persists the token into .git/config, which zizmor's artipacked audit flagged. The one push that needs credentials supplies them itself, so no later step in the job can read them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Invoke-PesterSuite.ps1 | 2 +- .github/workflows/Update-ModulePin.yml | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 index 0c35368..dd8f903 100644 --- a/.github/scripts/Invoke-PesterSuite.ps1 +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -51,7 +51,7 @@ param( # Exact Pester version to run with, so a new release cannot turn an untouched pull # request red. Keep it inside the range the suites' '#Requires' lines declare. [Parameter()] - [string] $RequiredVersion = '5.7.1', + [string] $RequiredVersion = '6.0.1', # Pester's module GUID — the identity half of the pin, checked after import so a # name-squatted module cannot satisfy the version. diff --git a/.github/workflows/Update-ModulePin.yml b/.github/workflows/Update-ModulePin.yml index 14208f2..11556c7 100644 --- a/.github/workflows/Update-ModulePin.yml +++ b/.github/workflows/Update-ModulePin.yml @@ -37,10 +37,13 @@ jobs: contents: write # push the update branch pull-requests: write # open the update pull request steps: - # Credentials are deliberately persisted here, unlike the read-only jobs in - # Docs.yml: this workflow pushes the update branch it creates. - 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 @@ -79,7 +82,7 @@ jobs: echo "A pull request for ${MODULE} ${LATEST} is already open. Nothing to do." exit 0 fi - if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + 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 @@ -91,7 +94,10 @@ jobs: 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." - git push --set-upstream origin "$branch" + # 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. @@ -120,6 +126,7 @@ jobs: } > "$body" gh pr create \ + --head "$branch" \ --title "⚙️ [Maintenance]: ${MODULE} test framework updated to ${LATEST}" \ --body-file "$body" \ --label dependencies \ From 97a77085b2e1aa1999bbc704a8cbf1dab11e267b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:23:35 +0200 Subject: [PATCH 7/8] Use the term runtime, which the natural-language linter requires Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/docs/Capabilities/dependency-updates/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Capabilities/dependency-updates/design.md b/src/docs/Capabilities/dependency-updates/design.md index a01e6a2..3e39959 100644 --- a/src/docs/Capabilities/dependency-updates/design.md +++ b/src/docs/Capabilities/dependency-updates/design.md @@ -145,7 +145,7 @@ Two properties make it trustworthy rather than merely present: 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 run time, so an + the same module. The consuming script still verifies it at runtime, so an identity mismatch fails the test check. ### The schedule can lapse, silently From 43715dde21781a8d8587710c45b4942cc3f5043c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 18:30:12 +0200 Subject: [PATCH 8/8] Treat a closed update pull request as a declined version The existing-pull-request guard looked only at open ones, so a version a reviewer had closed to decline would be proposed again on the next run, every week, once the branch was gone. Checking every state means closing is how you say no, and the bot listens. The guard stays per version because the branch name carries it: declining 6.1.0 says nothing about 6.1.1. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Update-ModulePin.yml | 10 +++++++--- src/docs/Capabilities/dependency-updates/design.md | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Update-ModulePin.yml b/.github/workflows/Update-ModulePin.yml index 11556c7..e18103f 100644 --- a/.github/workflows/Update-ModulePin.yml +++ b/.github/workflows/Update-ModulePin.yml @@ -77,9 +77,13 @@ jobs: module_slug=$(echo "$MODULE" | tr '[:upper:]' '[:lower:]') branch="maintenance/pin-${module_slug}-${LATEST}" - open_count=$(gh pr list --state open --head "$branch" --json number --jq 'length') - if [ "$open_count" -ne 0 ]; then - echo "A pull request for ${MODULE} ${LATEST} is already open. Nothing to do." + # '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 diff --git a/src/docs/Capabilities/dependency-updates/design.md b/src/docs/Capabilities/dependency-updates/design.md index 3e39959..912bdd9 100644 --- a/src/docs/Capabilities/dependency-updates/design.md +++ b/src/docs/Capabilities/dependency-updates/design.md @@ -148,6 +148,17 @@ Two properties make it trustworthy rather than merely present: 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,