Building a PowerShell Deploy Script: The Skeleton and the Two Callers
Building a real-world PowerShell deploy script. A 9 part series.
- Automating .NET Deployments With One PowerShell Script
- Flowerboxes: How to Write a Script Header Worth Keeping
- The Skeleton and the Two Callers (you are here)
- Choosing What and Where to Deploy
- Discovering Components From Disk (coming soon)
- Resolving the Deploy Target (coming soon)
- The Publish Phase and Its Gotchas (coming soon)
- The Main Loop, Safety and Provenance (coming soon)
- Testing a Tool That Can Do Damage (coming soon)
The code here is generalized from a real deploy script. My name stays on the work; every environment name, package name and path is an invented placeholder you would swap for your own.
The overview post laid out what this script does and the traps I hit. This series builds it back up one phase at a time, with sanitized code you can lift and adapt. We start where the script starts: the skeleton, and the single decision that shapes everything above it, that one script has to serve two very different callers.
Two callers, one script
A person runs this from their own machine and wants to be asked what to do. A pipeline runs it with everything pre-decided and must never be asked anything, because a prompt in CI hangs the job until it times out. Those two needs pull in opposite directions, and if you do not design for both from the first line, you end up with two scripts that drift apart.
So the skeleton has three jobs: define the inputs, accept them from a person or a pipeline, and refuse to guess the dangerous ones. Everything else in the series sits on top of this.
The parameter surface
Start with the public surface. The one rule that matters here: safe inputs get defaults, dangerous inputs do not.
[CmdletBinding()]
param(
# Safe defaults: fine to assume, low blast radius.
[string] $ReposRoot = "C:\build",
[string] $Configuration = "Release",
[bool] $RunTests = $true,
[bool] $DryRun = $false,
# Dangerous inputs: NO default. A destination must never be guessed,
# and the package is what lets one script serve every repo.
[string] $Package = "",
[string] $Environment = "",
# Only used when -Environment is Local.
[string] $LocalPublishRoot = "",
# Never prompt; turn anything missing into a hard error. Used by CI and tests.
[switch] $NonInteractive,
# Build everything and publish nothing, a validation pass.
[switch] $CompileOnly
)
Configuration defaulting to Release is harmless. Environment defaulting to anything is a loaded gun, because a bare accidental run could publish to a live server. So it starts empty and the script will force the caller to say. That single choice, no default on the target, is the most important line in the file.
Accept input three ways, in a fixed order
A person passes parameters or answers a menu. A pipeline usually passes environment variables. The script accepts all three, with a clear precedence: an explicit parameter wins, then an environment variable, then the interactive menu.
# How a runner usually supplies values. Precedence: parameter, then env var, then menu.
if (-not $Package -and $env:DEPLOY_PACKAGE) { $Package = $env:DEPLOY_PACKAGE }
if (-not $Environment -and $env:DEPLOY_ENVIRONMENT) { $Environment = $env:DEPLOY_ENVIRONMENT }
There is one subtlety worth stealing. For a parameter that has a default, like $ReposRoot, checking “is it empty” cannot tell “the caller passed it” apart from “we are using the default.” Both look the same. Ask whether the parameter was actually bound instead:
# $ReposRoot HAS a default, so emptiness is useless here. Ask if it was bound, so an
# explicit -ReposRoot is never silently overridden by the environment variable.
if (-not $PSBoundParameters.ContainsKey('ReposRoot') -and $env:DEPLOY_REPOS_ROOT) {
$ReposRoot = $env:DEPLOY_REPOS_ROOT
}
Without that, someone who explicitly passed -ReposRoot could still be quietly overruled by a stale environment variable, and spend an afternoon working out why.
Detect automation, do not declare it
Here is the decision that keeps a pipeline from hanging. You could add a -CI switch and trust callers to set it, but people forget, and a forgotten switch means a hung job. Detect it instead, from signals the environment already sets.
# A prompt in CI hangs the job forever, so automation is detected by EXPLICIT signal
# only: -NonInteractive or a known CI variable. NOT stdin redirection, because IDE
# terminals redirect stdin yet are perfectly interactive.
$AutomationReason = $null
if ($NonInteractive) { $AutomationReason = '-NonInteractive was passed' }
elseif ($env:GITHUB_ACTIONS) { $AutomationReason = 'GITHUB_ACTIONS is set' }
elseif ($env:TF_BUILD) { $AutomationReason = 'TF_BUILD is set' }
elseif ($env:GITLAB_CI) { $AutomationReason = 'GITLAB_CI is set' }
elseif ($env:JENKINS_URL) { $AutomationReason = 'JENKINS_URL is set' }
elseif ($env:TEAMCITY_VERSION) { $AutomationReason = 'TEAMCITY_VERSION is set' }
elseif ($env:CI) { $AutomationReason = 'CI is set' }
$IsAutomated = [bool] $AutomationReason
Two things make this hold up. Keeping the reason as text, not just a boolean, means every “I refuse to prompt” message can say exactly why it thinks it is in a pipeline, which saves a lot of guessing. And the deliberate choice not to treat redirected stdin as automation matters, because an earlier version did, and it wrongly denied a real person the menu inside their IDE terminal.
A menu that cannot hang an unknown runner
The CI list above covers the runners you know. What about a runner you have never heard of that sets none of those variables? It would fall through to the menu and hang. The prompt itself has to be the last line of defense.
function Read-Choice {
param([string] $Title, [string[]] $Options)
Write-Host ""
Write-Host $Title -ForegroundColor Cyan
for ($i = 0; $i -lt $Options.Count; $i++) {
Write-Host (" [{0,2}] {1}" -f ($i + 1), $Options[$i])
}
# A person blocks here until they type. An unknown runner hits end-of-input, so
# Read-Host returns empty at once. Repeated empty reads mean "nobody is there",
# not "keep waiting", so CI can never spin forever on a menu.
$emptyReads = 0
while ($true) {
$answer = Read-Host "Choose 1-$($Options.Count) (Q to quit)"
if ([string]::IsNullOrWhiteSpace($answer)) {
if (++$emptyReads -ge 3) {
Write-Host "No input available, so this is not an interactive session." -ForegroundColor Red
Write-Host "Pass values explicitly, e.g. -Package Orders -Environment Local." -ForegroundColor Red
exit 1
}
continue
}
$emptyReads = 0
if ($answer -match '^\s*[Qq]') { Write-Host "Cancelled." -ForegroundColor Yellow; exit 1 }
$n = 0
if ([int]::TryParse($answer, [ref] $n) -and $n -ge 1 -and $n -le $Options.Count) {
return $Options[$n - 1]
}
Write-Host "Not a valid choice." -ForegroundColor Red
}
}
Three empty reads in a row and it gives up with a useful message instead of waiting on a keypress that will never come. A real person who taps Enter by accident just gets asked again.
Required, or a hard error under automation
Now the two threads meet. For a value with no default, a person gets the menu and a pipeline gets a hard stop that tells it exactly how to fix the run.
if (-not $Package) {
if ($IsAutomated) {
Write-Host "-Package is required under automation ($AutomationReason)." -ForegroundColor Red
Write-Host "Pass -Package <name> or set DEPLOY_PACKAGE." -ForegroundColor Red
exit 1
}
$Package = Read-Choice -Title "Which package?" -Options @('Orders','Billing','Reporting')
}
The pipeline never sees a menu. The person never sees a stack trace. Both get told what to do next.
The contract everything else keeps
The last piece of the skeleton is a promise to whoever calls it:
# The whole script keeps one promise:
# exit 0 = success
# exit 1 = failure, and nothing partial was ever reported as success
A pipeline should check the exit code, never scrape the log text. Every phase we build in the rest of this series has to honor that promise. A build failure, a missing target, a wrong branch, all of them end in exit 1, and success is only ever exit 0 after everything actually shipped.
Next
That is the skeleton: a parameter surface that refuses to guess the dangerous inputs, three ways to supply values with a clear precedence, automation detected rather than declared, a menu that cannot hang, and one exit-code contract. Next time we build on it to decide what to deploy and where, and why the git branch is never allowed to pick the environment.
Your turn
How do you keep one script usable by both a human and a pipeline? Do you detect CI or trust a flag? And what is the worst “the job hung on a prompt” story you have? The comments are open.
// comments