If you’ve worked with psake, you know that psakefile.ps1
defines your build tasks — but it’s build.ps1 that ties everything together.
It’s the single entry point that bootstraps dependencies, invokes psake, and
makes sure CI pipelines get a proper exit code.
The PowerShell community has converged on a solid build.ps1 pattern. Projects
like PowerShellBuild,
PoshBot, and
psake itself all share the same core
structure. This post walks through the patterns I’ve added on top of that
baseline to handle the rough edges you hit in enterprise environments — module
lock contention, missing TLS protocols, internal package feeds, and developer
UX.
The Community Baseline
Most psake projects use a build.ps1 that looks something like this:
| |
This gives you a lot out of the box: -Bootstrap installs
PSDepend and your declared
dependencies, -Help lists available tasks,
Set-BuildEnvironment makes the script CI-aware, and
exit ([int](-not $psake.build_success)) translates psake’s success boolean
into a proper exit code so your pipeline fails when the build fails.
Credit where it’s due — this is a solid foundation. You can find real-world examples in PowerShellBuild, PoshBot, and devblackops/github-action-psscriptanalyzer. The rest of this post covers what I’ve added on top.
Clear Errors When Bootstrap Is Skipped
In the standard pattern, if a developer skips -Bootstrap and PSDepend isn’t
installed, the script fails with an opaque error — typically something like
Invoke-PSDepend: The term 'Invoke-PSDepend' is not recognized. That’s not
helpful for someone who just cloned the repo.
The fix is a simple guard:
| |
Now the developer gets a one-line message telling them exactly what to do. Small change, big improvement in onboarding experience.
Dynamic Tab Completion
The standard build.ps1 handles task names in one of two ways:
[ValidateSet()] with a hardcoded list (used by PoshBot, psake itself), or
[ArgumentCompleter] that calls Get-PSakeScriptTasks (used by
PowerShellBuild).
[ValidateSet()] works, but you have to update it every time you add or rename a
task. [ArgumentCompleter] reads the task list live from your psake file:
| |
The try/catch returning @() is important — it means tab completion degrades
gracefully if psake isn’t installed yet (before the first -Bootstrap run)
instead of throwing an error in the user’s terminal.
One gotcha: PSScriptAnalyzer will flag the completer’s parameters ($Command,
$Parameter, $CommandAst, $FakeBoundParams) as unused, even though they’re
required by the [ArgumentCompleter] contract. You’ll need
SuppressMessageAttribute declarations at the top of the script:
| |
Repeat for each parameter. It’s verbose, but it keeps your PSScriptAnalyzer output clean.
Try-Import-First Pattern
This is the pattern I haven’t seen in any other build.ps1 — and it’s the one
that’s saved me the most headaches.
The standard bootstrap calls Invoke-PSDepend -Install -Import, which downloads
and imports modules in one shot. That works fine for a single developer, but in
CI with concurrent jobs sharing a module cache, you can hit file lock errors when
one job is mid-install while another tries to do the same.
The fix: try importing first, only install if the import fails.
| |
If the modules are already present from a previous run (or a parallel job that finished first), the import-only path is instant and lock-free. You only pay the install cost when something is actually missing or outdated. The error handling also gives CI operators a clear next step when lock contention does occur.
Internal Repository Registration
Enterprise teams often host internal NuGet feeds — ProGet, Azure Artifacts, MyGet, or similar — rather than pulling everything from PSGallery. The bootstrap needs to register that repository before PSDepend can install from it.
The pattern is idempotent: check if the repo exists, register it if it doesn’t.
| |
One detail worth calling out: before registering, you may need to patch the TLS
protocol set. Some older Windows versions default to TLS 1.0/1.1, which modern
NuGet feeds reject. The key is to use -bor (bitwise OR) to add TLS 1.2 and
1.3 without removing whatever protocols are already enabled:
| |
Using -bor instead of assignment (=) means you don’t break connections that
legitimately need an older protocol. It’s a one-liner that prevents a class of
mysterious “unable to connect” errors in mixed-OS environments.
PowerShellGet Version Pinning
If your bootstrap registers internal repositories, you need PowerShellGet v2. Version 3 changed the module registration API and may not be available in all environments. Pinning to v2 avoids surprises:
| |
The MinimumVersion/MaximumVersion range ensures you get the latest v2.x
without accidentally pulling in v3. AllowClobber handles the case where a
different version is already loaded.
Wrapping Up
The community build.ps1 pattern gets you 80% of the way — bootstrap,
help, CI exit codes, and build environment detection are all table stakes. The
patterns above handle the remaining 20%: the edge cases that surface when you’re
running concurrent CI jobs, onboarding new developers, or pulling dependencies
from internal feeds.
None of these patterns are complex on their own. The value is in combining them
into a single entry point that just works — whether you’re running
.\build.ps1 -Bootstrap for the first time or kicking off your hundredth CI
build.
For further reading:
- psake — the build automation tool
- PowerShellBuild — common psake build tasks for PowerShell modules
- PSDepend — declarative dependency management