Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 39 additions & 23 deletions .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jobs:
lint:
name: PSScriptAnalyzer Lint
runs-on: ubuntu-latest
# Guard against a hung step burning the 6-hour default. Lint normally
# finishes in well under a minute.
timeout-minutes: 10
steps:
- uses: actions/checkout@v7

Expand All @@ -33,27 +36,37 @@ jobs:
echo "is_template=false" >> "$GITHUB_OUTPUT"
fi

- name: Cache PowerShell modules
if: steps.template_guard.outputs.is_template == 'false'
id: cache-lint-modules
uses: actions/cache@v6
with:
path: ~/.local/share/powershell/Modules
key: ${{ runner.os }}-psmodules-lint-${{ hashFiles('build.depend.psd1') }}
restore-keys: |
${{ runner.os }}-psmodules-lint-

# No module cache here on purpose -- see the note in the unit-tests job.
# The cache this replaced held 209 bytes: PSScriptAnalyzer ships on the
# runner image, so it was never installed into the cached path and the
# cache only ever restored an empty directory. Install only when the
# image does not already provide it, rather than unconditionally -- the
# module is ~339 MB on disk and re-downloading it every run is far more
# expensive than the cache ever saved.
- name: Install PSScriptAnalyzer
if: steps.template_guard.outputs.is_template == 'false' && steps.cache-lint-modules.outputs.cache-hit != 'true'
if: steps.template_guard.outputs.is_template == 'false'
shell: pwsh
run: |
# Pin to the version build.depend.psd1 declares, so lint results here match
# a local ./build.ps1 -Task Analyze. Accepting whatever the runner image
# happens to ship means the two can disagree silently.
$required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version
$installed = Get-Module -Name PSScriptAnalyzer -ListAvailable |
Where-Object { $_.Version -eq $required }
if ($installed) {
Write-Host "PSScriptAnalyzer $required already available; skipping install."
return
}

Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser
Install-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -Scope CurrentUser

- name: Run PSScriptAnalyzer
if: steps.template_guard.outputs.is_template == 'false'
shell: pwsh
run: |
$required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version
Import-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -ErrorAction Stop
$results = Invoke-ScriptAnalyzer -Path ./{{ModuleName}} -Recurse -Settings PSGallery -ReportSummary
$errors = $results | Where-Object { $_.Severity -eq 'Error' }

Expand All @@ -73,6 +86,13 @@ jobs:
unit-tests:
name: Unit Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Guard against a hung step burning the 6-hour default. A cold run --
# installing every dependency from the gallery -- completes in about
# 90 seconds. The specific hazard this caught: BuildHelpers' Invoke-Git
# calls WaitForExit() before draining stdout, so on Windows a HEAD commit
# message larger than the 4096-byte pipe buffer deadlocks the build with
# no output at all. Keep merge-commit messages short.
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
Expand All @@ -99,17 +119,13 @@ jobs:
echo "is_template=false" >> "$GITHUB_OUTPUT"
fi

- name: Cache PowerShell modules
if: steps.template_guard.outputs.is_template == 'false'
uses: actions/cache@v6
with:
path: |
~/Documents/PowerShell/Modules
~/.local/share/powershell/Modules
key: ${{ runner.os }}-psmodules-${{ hashFiles('build.depend.psd1') }}
restore-keys: |
${{ runner.os }}-psmodules-

# No module cache here on purpose. Measured on a derived repo, same tree:
# ubuntu 27s warm / 27s cold, macOS 16s / 26s, Windows 28s / 50s. The
# three jobs run in parallel, so the cache bought at most ~22s of
# wall-clock. Against that it made pull-request runs warm and main runs
# cold, so a PR could pass on a code path main never exercised.
# build.ps1 -Bootstrap gates installation on Invoke-PSDepend -Test, so a
# runner that already satisfies the dependency file does no install work.
- name: Build and Test
if: steps.template_guard.outputs.is_template == 'false'
shell: pwsh
Expand Down
2 changes: 1 addition & 1 deletion build.depend.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}
}
'Pester' = @{
Version = '6.0.1'
Version = 'latest'
Parameters = @{
SkipPublisherCheck = $true
}
Expand Down
101 changes: 82 additions & 19 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -67,52 +67,115 @@ $dependencyFilePath = Join-Path -Path $PSScriptRoot -ChildPath $dependencyFilePa
if ($Bootstrap) {
$null = PackageManagement\Get-PackageProvider -Name 'NuGet' -ForceBootstrap
if ((Test-Path -Path $dependencyFilePath)) {
# Ensure PSGallery is registered and trusted
if (-not (Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue')) {
Register-PSRepository -Default
# Ensure PSGallery is registered and trusted.
#
# Register-PSRepository -Default shells out to nuget.exe, which on some Windows
# runner images fails with:
#
# NuGet.Commands.CommandException: Missing option value for: '-source'
#
# That leaves PSGallery unregistered, and the Set-PSRepository call that used to
# follow it unconditionally then died with "No repository with the name
# 'PSGallery' was found", masking the real cause. Fall back to registering the
# gallery explicitly by URL, and only configure it once it actually exists.
$psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue'
if (-not $psGallery) {
try {
Register-PSRepository -Default -ErrorAction 'Stop'
}
catch {
Write-Verbose "Register-PSRepository -Default failed ($($_.Exception.Message)); registering PSGallery explicitly." -Verbose
}

$psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue'
if (-not $psGallery) {
$registerParameters = @{
Name = 'PSGallery'
SourceLocation = 'https://www.powershellgallery.com/api/v2'
InstallationPolicy = 'Trusted'
ErrorAction = 'Stop'
}
Register-PSRepository @registerParameters
$psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue'
}
}

if (-not $psGallery) {
throw 'Could not register the PSGallery repository; build dependencies cannot be installed.'
}

if ($psGallery.InstallationPolicy -ne 'Trusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted'
}
Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted'

if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) {
Install-Module -Name 'PSDepend' -Scope 'CurrentUser' -Repository 'PSGallery' -Force
}
Import-Module -Name 'PSDepend' -Verbose:$false

# Try to import existing modules first to avoid installation locks
# Only install if import fails (missing modules or wrong versions)
$psDependParameters = @{
Path = $PSScriptRoot
Recurse = $False
WarningAction = 'SilentlyContinue'
Import = $True
Force = $True
ErrorAction = 'Stop'
}

$importSucceeded = $false
# Install before importing, never the other way round.
#
# This used to attempt an import first and only install if that failed, to avoid
# installation locks. That is safe on a warm cache, where the requested versions
# are already present, but wrong on a cold one: the import pass loads whatever
# version happens to be on the machine already -- typically an older Pester from
# the runner image -- and Pester ships a binary Pester.dll that cannot then be
# replaced in-process by the version the subsequent install brings in:
#
# An incompatible version of the Pester.dll assembly is already loaded.
# The loaded dll version is 5.9.0.0, but at least version 6.1.0 is required
#
# -Test reports whether each dependency is already satisfied without importing
# anything, so it is safe to run before anything is loaded (measured at ~6s here).
# Gate the install on it so a satisfied machine does no install work at all --
# -Install carries -Force, which re-resolves and re-downloads every dependency.
$dependenciesSatisfied = $false
try {
Invoke-PSDepend @psDependParameters
$importSucceeded = $true
Write-Verbose 'Successfully imported existing modules.' -Verbose
$testResults = @(Invoke-PSDepend @psDependParameters -Test -Quiet)
$dependenciesSatisfied = $testResults.Count -gt 0 -and $testResults -notcontains $false
}
catch {
Write-Verbose "Could not import all required modules: $_" -Verbose
Write-Verbose 'Attempting to install missing or outdated dependencies...' -Verbose
Write-Verbose "Could not determine dependency status: $_" -Verbose
}

# If import failed, install the dependencies
if (-not $importSucceeded) {
if (-not $dependenciesSatisfied) {
Write-Verbose 'Installing missing or outdated dependencies...' -Verbose
try {
Invoke-PSDepend @psDependParameters -Install
}
catch {
Write-Error "Failed to install and import required dependencies: $_"
Write-Error 'This may be due to locked module files. Please restart the build environment or clear module locks.'
# Compose one message and throw it, rather than emitting several
# Write-Error calls: $ErrorActionPreference is 'Stop' in this script, so
# the first Write-Error terminates and every diagnostic after it -- the
# lock hint, the inner exception -- is silently dropped.
$installError = "Failed to install required dependencies: $($_.Exception.Message)"
if ($_.Exception.InnerException) {
Write-Error "Inner exception: $($_.Exception.InnerException.Message)"
$installError += " Inner exception: $($_.Exception.InnerException.Message)"
}
throw
$installError += ' This may be due to locked module files; restart the build environment or clear module locks.'
throw $installError
}
}

try {
Invoke-PSDepend @psDependParameters -Import
Write-Verbose 'Successfully imported required modules.' -Verbose
}
catch {
# Single composed throw -- see the note in the install catch above.
$importError = "Failed to import required dependencies: $($_.Exception.Message)"
if ($_.Exception.InnerException) {
$importError += " Inner exception: $($_.Exception.InnerException.Message)"
}
throw $importError
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
else {
Expand Down
Loading