Building a PowerShell Deploy Script: Choosing What and Where to Deploy
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
- Choosing What and Where to Deploy (you are here)
- 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)
Code here is generalized. Environment names, package names and paths are invented placeholders you would swap for your own.
Part 3 built the skeleton and the rule that dangerous inputs get no default. This part is about the two dangerous inputs themselves: which package to deploy, and which environment to deploy it to. Getting the second one wrong is how code ends up on a production server by accident, so most of this post is about refusing to guess it.
Two lists, both required
Start by naming the environments and what each one means. A person sees the notes in the menu; the script uses the list to reject anything invalid.
$ValidEnvironments = @('OnPrem','Local','Staging','Cert','Prod')
$EnvironmentHelp = [ordered]@{
'OnPrem' = 'publish exactly where each profile points, no override'
'Local' = 'local test, redirect everything into -LocalPublishRoot'
'Staging'= 'the staging servers'
'Cert' = 'the certification servers'
'Prod' = 'PRODUCTION'
}
Neither the package nor the environment has a default. The package is what lets one script serve every repo, and the environment is the destination, which must never be assumed.
The package menu
A person with no arguments gets a menu of every known package, annotated with whether its repo is actually cloned on this machine. Offering all of them, cloned or not, means the menu is complete even on a fresh box, and you can still preview a plan with -DryRun.
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
}
$cloned = Get-ClonedRepoKeys # repo folders present under -ReposRoot
$help = [ordered]@{}
foreach ($name in $KnownPackages.Keys) {
$key = Resolve-RepoKey $name
$help[$name] = if ($cloned -contains $key) { "$key (cloned)" } else { "$key (not on this machine)" }
}
$Package = Read-Choice -Title "Which application do you want to deploy?" `
-Options @($KnownPackages.Keys) -Help $help
}
The important habit here is telling the person the state of the world before they choose, not after. Finding out at the very end that the repo was never cloned wastes everyone’s time.
The environment, three ways, and never from the branch
A person picks the environment from a menu. A pipeline supplies it through an explicit environment name, and only an explicit one. The branch is deliberately never consulted.
# An EXPLICIT pipeline environment only. Returns $null if the pipeline did not say.
function Get-DetectedEnvironment {
foreach ($candidate in @(
@{ Var = 'DEPLOY_ENVIRONMENT'; Value = $env:DEPLOY_ENVIRONMENT },
@{ Var = 'GITHUB_ENVIRONMENT'; Value = $env:GITHUB_ENVIRONMENT }
)) {
if ($candidate.Value) {
if ($candidate.Value -in $ValidEnvironments) {
return @{ Value = $candidate.Value; Reason = "$($candidate.Var)=$($candidate.Value)" }
}
Write-Host "$($candidate.Var) is '$($candidate.Value)', not a valid environment." -ForegroundColor Red
exit 1
}
}
return $null
}
Then the resolution itself:
if (-not $Environment) {
if ($IsAutomated) {
$detected = Get-DetectedEnvironment
if ($detected) {
$Environment = $detected.Value
Write-Host "Environment DETECTED: $Environment ($($detected.Reason))" -ForegroundColor Green
}
else {
$branch = Get-CurrentBranch
Write-Host "Cannot determine the target environment ($AutomationReason)." -ForegroundColor Red
if ($branch) {
Write-Host "The branch is '$branch', but the branch does NOT decide the environment." -ForegroundColor Red
}
Write-Host "Fix by one of: -Environment <name>, DEPLOY_ENVIRONMENT, or a job environment." -ForegroundColor Red
exit 1
}
}
else {
$Environment = Read-Choice -Title "Where do you want to deploy $Package?" `
-Options $ValidEnvironments -Help $EnvironmentHelp
}
}
Why the branch is off limits
It is tempting to say “the main branch means production” and be done. Do not. On this project none of main, develop or release mapped cleanly to an environment. The same branch goes to different environments over its life, so a branch rule would be a guess wearing a rule’s clothing. An earlier version did infer staging from a branch name, and it was removed the moment we confirmed the branches carry no environment meaning.
The branch is still worth knowing, just not for choosing the target. It gets recorded so the log says exactly what shipped, and nothing more.
# Worked out only to RECORD it. Never used to choose a destination.
function Get-CurrentBranch {
if ($env:GITHUB_REF_NAME) { return $env:GITHUB_REF_NAME }
if ($env:BUILD_SOURCEBRANCHNAME) { return $env:BUILD_SOURCEBRANCHNAME }
$repo = Join-Path $ReposRoot (Resolve-RepoKey $Package)
if (Test-Path $repo) {
$name = & git -C $repo rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $name) { return $name.Trim() }
}
return $null
}
A guard for real deploys
Refusing to infer the environment from the branch does not mean the branch is irrelevant to safety. A server deploy should be built from your release branch, so the script checks, and the two callers behave differently again. A person is warned and asked. A pipeline hard-stops unless it was explicitly told to allow it, so a runner can never quietly ship whatever happened to be checked out.
$ExpectedDeployBranch = 'release'
if ($Environment -ne 'Local' -and -not $DryRun) {
$branch = Get-CurrentBranch
if ($branch -and $branch -ne $ExpectedDeployBranch) {
Write-Host "WARNING: a $Environment deploy expects '$ExpectedDeployBranch', source is on '$branch'." -ForegroundColor Yellow
if ($IsAutomated) {
if (-not $AllowNonReleaseBranch) {
Write-Host "Refusing to deploy from '$branch' under automation." -ForegroundColor Red
Write-Host "Check out '$ExpectedDeployBranch', or pass -AllowNonReleaseBranch to override." -ForegroundColor Red
exit 1
}
}
else {
$answer = Read-Host "Deploy from '$branch' anyway? (y/N)"
if ($answer -notmatch '^\s*[Yy]') { Write-Host "Stopped, nothing was deployed." -ForegroundColor Yellow; exit 1 }
}
}
}
Local and dry runs skip the check, because neither touches a server. Note the script never checks the branch out or pulls it; that is an upstream job. It only reads what is there and refuses to proceed when it looks wrong.
Local needs somewhere to go
One last input. A Local deploy has no server, so it needs a destination folder. Ask for it interactively rather than making the person re-run with a parameter, and make it a hard error under automation so a missing folder cannot masquerade as a successful deploy.
if ($Environment -eq 'Local' -and -not $LocalPublishRoot) {
if ($IsAutomated) {
Write-Host "-Environment Local requires -LocalPublishRoot ($AutomationReason)." -ForegroundColor Red
exit 1
}
$suggested = Join-Path $env:TEMP "deploy-local\$Package"
$answer = Read-Host "Local publish folder (Enter for $suggested)"
$LocalPublishRoot = if ([string]::IsNullOrWhiteSpace($answer)) { $suggested } else { $answer.Trim(' ','"') }
}
Next
We now know what to deploy and where. Next time the script works out the how: discovering the actual components from the repos on disk, in the right order, without a hand-maintained list.
Your turn
Does your pipeline tie environments to branches? Has that ever shipped the wrong thing? And do you guard against deploying from an unexpected branch, or trust the pipeline to only ever run on the right one? Tell me in the comments.
// comments