From 2ea8854b4841440fc24686f863374cf068b6af72 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 8 Apr 2026 19:59:03 -0400 Subject: [PATCH 1/4] Add resilient build.ps1 blog post and author entry Add Trent Blackburn as a contributor in authors.yml and publish a slimmed-down version of the "Building a Resilient build.ps1" post, linking to the full walkthrough on the author's personal blog. Co-Authored-By: Claude Opus 4.6 (1M context) --- authors.choices.jsonc | 39 ++++--- blog/2026-04-08-resilient-build-ps1.md | 135 +++++++++++++++++++++++++ blog/authors.yml | 9 ++ 3 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 blog/2026-04-08-resilient-build-ps1.md diff --git a/authors.choices.jsonc b/authors.choices.jsonc index 537665b..dae16c8 100644 --- a/authors.choices.jsonc +++ b/authors.choices.jsonc @@ -1,17 +1,28 @@ [ - { - "_comment": "This file is auto-generated from blog/authors.yml via a psake task" - }, - { - "url": "https://gilbertsanchez.com", - "title": "Core Contributor", - "image_url": "https://github.com/heyitsgilbert.png", - "name": "Gilbert Sanchez", - "socials": { - "linkedin": "gilbertsanchez", - "github": "HeyItsGilbert", - "x": "HeyItsGilbertS" + { + "_comment": "This file is auto-generated from blog/authors.yml via a psake task" }, - "handle": "heyitsgilbert" - } + { + "url": "https://gilbertsanchez.com", + "handle": "heyitsgilbert", + "image_url": "https://github.com/heyitsgilbert.png", + "name": "Gilbert Sanchez", + "socials": { + "linkedin": "gilbertsanchez", + "github": "HeyItsGilbert", + "x": "HeyItsGilbertS" + }, + "title": "Core Contributor" + }, + { + "url": "https://tablackburn.github.io", + "handle": "tablackburn", + "image_url": "https://github.com/tablackburn.png", + "name": "Trent Blackburn", + "socials": { + "linkedin": "trentblackburn", + "github": "tablackburn" + }, + "title": "Contributor" + } ] diff --git a/blog/2026-04-08-resilient-build-ps1.md b/blog/2026-04-08-resilient-build-ps1.md new file mode 100644 index 0000000..9e05345 --- /dev/null +++ b/blog/2026-04-08-resilient-build-ps1.md @@ -0,0 +1,135 @@ +--- +title: "Building a Resilient build.ps1 for psake Projects" +description: "Extend your psake build.ps1 with clear error handling, dynamic tab completion, CI-safe module imports, and more." +date: 2026-04-08T18:00:00.000Z +slug: resilient-build-ps1 +authors: + - tablackburn +tags: + - psake + - powershell + - build-automation + - ci-cd + - best-practices +keywords: + - psake + - build.ps1 + - PowerShell + - build automation + - CI/CD +image: /img/social-card.png +draft: false +fmContentType: blog +title_meta: "Building a Resilient build.ps1" +--- + +The standard psake `build.ps1` gives you a solid foundation — bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. + + + +## Clear Error Handling + +The default script silently fails with cryptic "term not recognized" errors when dependencies aren't installed. A simple guard clause makes the fix obvious: + +```powershell +if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { + throw 'Missing dependencies. Please run with the "-Bootstrap" flag to install dependencies.' +} +``` + +Instead of hunting through stack traces, new contributors get a one-line message telling them exactly what to do. + +## Dynamic Tab Completion + +Hardcoded `[ValidateSet()]` values require manual updates every time you add a task. Replace them with `[ArgumentCompleter]` for live discovery: + +```powershell +[ArgumentCompleter( { + param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) + try { + Get-PSakeScriptTasks -BuildFile './build.psake.ps1' -ErrorAction 'Stop' | + Where-Object { $_.Name -like "$WordToComplete*" } | + Select-Object -ExpandProperty 'Name' + } + catch { + @() + } + })] +[string[]]$Task = 'default', +``` + +Now tab completion always reflects the actual tasks in your psake file — no maintenance required. + +## Try-Import-First Pattern + +When parallel CI jobs share a module cache, `Install-Module` can hit file locks and fail. The fix: try importing existing modules first, and only install if the import fails. + +```powershell +$importSucceeded = $false +try { + Invoke-PSDepend @psDependParameters + $importSucceeded = $true + Write-Verbose 'Successfully imported existing modules.' -Verbose +} +catch { + Write-Verbose "Could not import all required modules: $_" -Verbose + Write-Verbose 'Attempting to install missing or outdated dependencies...' -Verbose +} + +if (-not $importSucceeded) { + try { + Invoke-PSDepend @psDependParameters -Install + } + catch { + Write-Error "Failed to install and import required dependencies: $_" + throw + } +} +``` + +This eliminates a common source of flaky builds in enterprise CI pipelines. + +## Internal Repository Support + +Organizations often host modules on internal NuGet feeds (ProGet, Azure Artifacts, etc.). Idempotent registration keeps the script portable: + +```powershell +$repositoryName = 'internal-nuget-repo' +if (-not (Get-PSRepository -Name $repositoryName -ErrorAction 'SilentlyContinue')) { + Register-PSRepository @registerPSRepositorySplat +} +``` + +Pair this with TLS protocol patching to ensure compatibility with modern security requirements: + +```powershell +[System.Net.ServicePointManager]::SecurityProtocol = ( + [System.Net.ServicePointManager]::SecurityProtocol -bor + [System.Net.SecurityProtocolType]::Tls12 -bor + [System.Net.SecurityProtocolType]::Tls13 +) +``` + +## PowerShellGet Version Pinning + +PowerShellGet v3 introduces breaking API changes. Pinning to v2.x keeps behavior predictable: + +```powershell +$powerShellGetModuleParameters = @{ + Name = 'PowerShellGet' + MinimumVersion = '2.0.0' + MaximumVersion = '2.99.99' + Force = $true +} + +if (-not $powerShellGetModule) { + Install-Module @powerShellGetModuleParameters -Scope 'CurrentUser' -AllowClobber +} +Import-Module @powerShellGetModuleParameters +``` + +This prevents surprise breakage when a CI agent picks up a new PowerShellGet version. + +## Get the Complete Script + +These five patterns combine into a production-ready 143-line bootstrap that handles concurrent CI pipelines, enterprise package management, and mixed OS environments. For the full walkthrough and complete script, check out the [original post on my blog](https://tablackburn.github.io/p/resilient-build-ps1/). diff --git a/blog/authors.yml b/blog/authors.yml index 8fb687e..9774c57 100644 --- a/blog/authors.yml +++ b/blog/authors.yml @@ -7,3 +7,12 @@ heyitsgilbert: x: HeyItsGilbertS linkedin: gilbertsanchez github: HeyItsGilbert + +tablackburn: + name: Trent Blackburn + title: Contributor + url: https://tablackburn.github.io + image_url: https://github.com/tablackburn.png + socials: + github: tablackburn + linkedin: trentblackburn From 028326ad5fda27a2607c2194c9c94cb6ae041ed6 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 9 Apr 2026 16:32:17 -0400 Subject: [PATCH 2/4] Rewrite blog post intro for newcomer clarity Address PR feedback to explain what build.ps1 is before diving into resilience patterns. Links to the default build.ps1 in the psake repo. Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/2026-04-08-resilient-build-ps1.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blog/2026-04-08-resilient-build-ps1.md b/blog/2026-04-08-resilient-build-ps1.md index 9e05345..8583254 100644 --- a/blog/2026-04-08-resilient-build-ps1.md +++ b/blog/2026-04-08-resilient-build-ps1.md @@ -23,7 +23,9 @@ fmContentType: blog title_meta: "Building a Resilient build.ps1" --- -The standard psake `build.ps1` gives you a solid foundation — bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. +In psake projects, `build.ps1` is the entry point script that wires everything together. It installs dependencies, configures the environment, and hands off to psake to run your actual build tasks. Think of it as the bootstrapper that gets a fresh machine — or a CI agent — from zero to a working build in a single command: `.\build.ps1`. + +The [default `build.ps1`](https://github.com/psake/psake/blob/main/build.ps1) that ships with psake handles the basics well: bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. From 0d0926c4dee917b040f9ad03172f1493e0adb0ff Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 10 Apr 2026 13:19:46 -0400 Subject: [PATCH 3/4] Clarify that build.ps1 is a convention, not shipped by psake Addresses PR #48 feedback: psake doesn't ship a build.ps1, it's a best practice. Reframe the intro to present it as a community convention and link to the psake repo as an example rather than a "default." Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/2026-04-08-resilient-build-ps1.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blog/2026-04-08-resilient-build-ps1.md b/blog/2026-04-08-resilient-build-ps1.md index 8583254..7d4d3c2 100644 --- a/blog/2026-04-08-resilient-build-ps1.md +++ b/blog/2026-04-08-resilient-build-ps1.md @@ -23,9 +23,9 @@ fmContentType: blog title_meta: "Building a Resilient build.ps1" --- -In psake projects, `build.ps1` is the entry point script that wires everything together. It installs dependencies, configures the environment, and hands off to psake to run your actual build tasks. Think of it as the bootstrapper that gets a fresh machine — or a CI agent — from zero to a working build in a single command: `.\build.ps1`. +In psake projects, a `build.ps1` is the conventional entry point script that wires everything together. It installs dependencies, configures the environment, and hands off to psake to run your actual build tasks. Think of it as the bootstrapper that gets a fresh machine — or a CI agent — from zero to a working build in a single command: `.\build.ps1`. -The [default `build.ps1`](https://github.com/psake/psake/blob/main/build.ps1) that ships with psake handles the basics well: bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. +psake itself doesn't ship a `build.ps1` — it's a best practice that most projects adopt. You can see a good [example in the psake repo itself](https://github.com/psake/psake/blob/main/build.ps1), which covers the basics: bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. From 4df1da97d25c981162658c2d7c40e163bc107da9 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Sat, 11 Apr 2026 17:58:05 -0400 Subject: [PATCH 4/4] Streamline blog post: embed full script, remove snippets, fix quoting - Remove individual pattern snippets and explanations (the five sections) - Embed the complete 143-line build.ps1 directly in the post - Move blog link above the script as the pitch for detailed coverage - Remove misleading link to psake repo's build.ps1 (doesn't use psake) - Quote all literal string parameter values with single quotes Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/2026-04-08-resilient-build-ps1.md | 219 +++++++++++++++---------- 1 file changed, 130 insertions(+), 89 deletions(-) diff --git a/blog/2026-04-08-resilient-build-ps1.md b/blog/2026-04-08-resilient-build-ps1.md index 7d4d3c2..7a49136 100644 --- a/blog/2026-04-08-resilient-build-ps1.md +++ b/blog/2026-04-08-resilient-build-ps1.md @@ -25,113 +25,154 @@ title_meta: "Building a Resilient build.ps1" In psake projects, a `build.ps1` is the conventional entry point script that wires everything together. It installs dependencies, configures the environment, and hands off to psake to run your actual build tasks. Think of it as the bootstrapper that gets a fresh machine — or a CI agent — from zero to a working build in a single command: `.\build.ps1`. -psake itself doesn't ship a `build.ps1` — it's a best practice that most projects adopt. You can see a good [example in the psake repo itself](https://github.com/psake/psake/blob/main/build.ps1), which covers the basics: bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. Here are five patterns that harden your entry point for the real world. +psake itself doesn't ship a `build.ps1` — it's a best practice that most projects adopt. A typical starter script covers the basics: bootstrap installation, help output, build environment detection, and proper CI exit codes. But once you're running concurrent CI jobs, managing internal package feeds, or onboarding new contributors, a few gaps start to show. -## Clear Error Handling - -The default script silently fails with cryptic "term not recognized" errors when dependencies aren't installed. A simple guard clause makes the fix obvious: +The script below addresses those gaps with clear error handling, dynamic tab completion, CI-safe module imports, internal repository support, and PowerShellGet version pinning. I cover each pattern in detail in the [full post on my blog](https://tablackburn.github.io/p/resilient-build-ps1/) — but here's the complete script. Copy it into your project and adjust the repository name and URL to match your environment: ```powershell -if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { - throw 'Missing dependencies. Please run with the "-Bootstrap" flag to install dependencies.' -} -``` - -Instead of hunting through stack traces, new contributors get a one-line message telling them exactly what to do. - -## Dynamic Tab Completion +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'Command', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'Parameter', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'CommandAst', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'FakeBoundParams', + Justification = 'false positive' +)] +[CmdletBinding(DefaultParameterSetName = 'task')] +param( + [parameter(ParameterSetName = 'task', Position = 0)] + [ArgumentCompleter( { + param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) + try { + Get-PSakeScriptTasks -BuildFile './psakeFile.ps1' -ErrorAction 'Stop' | + Where-Object { $_.Name -like "$WordToComplete*" } | + Select-Object -ExpandProperty 'Name' + } + catch { + @() + } + })] + [string[]]$Task = 'default', + [switch]$Bootstrap, + [parameter(ParameterSetName = 'Help')] + [switch]$Help +) -Hardcoded `[ValidateSet()]` values require manual updates every time you add a task. Replace them with `[ArgumentCompleter]` for live discovery: +$ErrorActionPreference = 'Stop' +$psakeFile = './psakeFile.ps1' + +if ($Bootstrap) { + # Patch TLS protocols for older Windows versions + [System.Net.ServicePointManager]::SecurityProtocol = ( + [System.Net.ServicePointManager]::SecurityProtocol -bor + [System.Net.SecurityProtocolType]::Tls12 -bor + [System.Net.SecurityProtocolType]::Tls13 + ) + + Get-PackageProvider -Name 'Nuget' -ForceBootstrap | Out-Null + Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted' + + # Pin PowerShellGet to v2 + $powerShellGetModule = Get-Module -Name 'PowerShellGet' -ListAvailable | + Where-Object { $_.Version.Major -eq 2 } | + Sort-Object -Property 'Version' -Descending | + Select-Object -First 1 + + $powerShellGetModuleParameters = @{ + Name = 'PowerShellGet' + MinimumVersion = '2.0.0' + MaximumVersion = '2.99.99' + Force = $true + } -```powershell -[ArgumentCompleter( { - param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) - try { - Get-PSakeScriptTasks -BuildFile './build.psake.ps1' -ErrorAction 'Stop' | - Where-Object { $_.Name -like "$WordToComplete*" } | - Select-Object -ExpandProperty 'Name' - } - catch { - @() + if (-not $powerShellGetModule) { + Install-Module @powerShellGetModuleParameters -Scope 'CurrentUser' -AllowClobber + } + Import-Module @powerShellGetModuleParameters + + # Register internal repository (idempotent) + $repositoryName = 'internal-nuget-repo' + if (-not (Get-PSRepository -Name $repositoryName -ErrorAction 'SilentlyContinue')) { + $repositoryUrl = "https://nuget.example.com/api/v2/$repositoryName" + $registerPSRepositorySplat = @{ + Name = $repositoryName + SourceLocation = $repositoryUrl + PublishLocation = $repositoryUrl + ScriptSourceLocation = $repositoryUrl + InstallationPolicy = 'Trusted' + PackageManagementProvider = 'NuGet' } - })] -[string[]]$Task = 'default', -``` - -Now tab completion always reflects the actual tasks in your psake file — no maintenance required. - -## Try-Import-First Pattern + Register-PSRepository @registerPSRepositorySplat + } -When parallel CI jobs share a module cache, `Install-Module` can hit file locks and fail. The fix: try importing existing modules first, and only install if the import fails. + # Install PSDepend if missing + if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { + Install-Module -Name 'PSDepend' -Repository 'PSGallery' -Scope 'CurrentUser' -Force + } -```powershell -$importSucceeded = $false -try { - Invoke-PSDepend @psDependParameters - $importSucceeded = $true - Write-Verbose 'Successfully imported existing modules.' -Verbose -} -catch { - Write-Verbose "Could not import all required modules: $_" -Verbose - Write-Verbose 'Attempting to install missing or outdated dependencies...' -Verbose -} + # Try-import-first pattern + $psDependParameters = @{ + Path = $PSScriptRoot + Recurse = $False + WarningAction = 'SilentlyContinue' + Import = $True + Force = $True + ErrorAction = 'Stop' + } -if (-not $importSucceeded) { + $importSucceeded = $false try { - Invoke-PSDepend @psDependParameters -Install + Invoke-PSDepend @psDependParameters + $importSucceeded = $true + Write-Verbose 'Successfully imported existing modules.' -Verbose } catch { - Write-Error "Failed to install and import required dependencies: $_" - throw + Write-Verbose "Could not import all required modules: $_" -Verbose + Write-Verbose 'Attempting to install missing or outdated dependencies...' -Verbose } -} -``` - -This eliminates a common source of flaky builds in enterprise CI pipelines. -## Internal Repository Support - -Organizations often host modules on internal NuGet feeds (ProGet, Azure Artifacts, etc.). Idempotent registration keeps the script portable: - -```powershell -$repositoryName = 'internal-nuget-repo' -if (-not (Get-PSRepository -Name $repositoryName -ErrorAction 'SilentlyContinue')) { - Register-PSRepository @registerPSRepositorySplat + if (-not $importSucceeded) { + 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.' + if ($_.Exception.InnerException) { + Write-Error "Inner exception: $($_.Exception.InnerException.Message)" + } + throw + } + } } -``` - -Pair this with TLS protocol patching to ensure compatibility with modern security requirements: - -```powershell -[System.Net.ServicePointManager]::SecurityProtocol = ( - [System.Net.ServicePointManager]::SecurityProtocol -bor - [System.Net.SecurityProtocolType]::Tls12 -bor - [System.Net.SecurityProtocolType]::Tls13 -) -``` - -## PowerShellGet Version Pinning - -PowerShellGet v3 introduces breaking API changes. Pinning to v2.x keeps behavior predictable: - -```powershell -$powerShellGetModuleParameters = @{ - Name = 'PowerShellGet' - MinimumVersion = '2.0.0' - MaximumVersion = '2.99.99' - Force = $true +else { + if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { + throw 'Missing dependencies. Please run with the "-Bootstrap" flag to install dependencies.' + } + Invoke-PSDepend -Path $PSScriptRoot -Recurse $False -WarningAction 'SilentlyContinue' -Import -Force } -if (-not $powerShellGetModule) { - Install-Module @powerShellGetModuleParameters -Scope 'CurrentUser' -AllowClobber +if ($PSCmdlet.ParameterSetName -eq 'Help') { + Get-PSakeScriptTasks -buildFile $psakeFile | + Format-Table -Property Name, Description, Alias, DependsOn +} +else { + Set-BuildEnvironment -Force + Invoke-psake -buildFile $psakeFile -taskList $Task -nologo + exit ([int](-not $psake.build_success)) } -Import-Module @powerShellGetModuleParameters ``` - -This prevents surprise breakage when a CI agent picks up a new PowerShellGet version. - -## Get the Complete Script - -These five patterns combine into a production-ready 143-line bootstrap that handles concurrent CI pipelines, enterprise package management, and mixed OS environments. For the full walkthrough and complete script, check out the [original post on my blog](https://tablackburn.github.io/p/resilient-build-ps1/).