Building a PowerShell Deploy Script: Discovering Components From Disk
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
- Discovering Components From Disk (you are here)
- 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. Repo names, component names and paths are invented placeholders.
By Part 4 the script knows what package and which environment. Now it has to work out the how: which components make up that package, in what order they deploy, which publish profile each uses, and where each one goes. The tempting move is a big table you edit by hand for every project. Do not. That table rots the first time someone adds a component and forgets. Read it off the disk instead.
The shape rule
The whole approach rests on one convention: every project lives at <base>\<Name>\<Name>.csproj. If your repos hold to a shape like that, a folder listing IS your component list, and you never maintain a list again.
Real repos are not perfectly uniform though, so path resolution tries the documented shape first and works the rest out from the layout.
# Does this folder directly contain at least one <Name>\<Name>.csproj?
function Test-LooksLikeRepoBase {
param([string] $Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $false }
foreach ($dir in (Get-ChildItem -LiteralPath $Path -Directory -ErrorAction SilentlyContinue)) {
if (Test-Path -LiteralPath (Join-Path $dir.FullName "$($dir.Name).csproj")) { return $true }
}
return $false
}
function Resolve-RepoBase {
param([string] $Repo)
$root = Join-Path $ReposRoot $Repo
# Documented shape first: the shared repo keeps projects at the root, app
# repos keep them under src\. Known-good repos behave exactly as expected.
$expected = if ($Repo -eq 'common') { $root } else { Join-Path $root 'src' }
if (Test-LooksLikeRepoBase $expected) { return $expected }
# Otherwise find the base by looking for where the projects actually are.
$found = Find-RepoBase $root
if ($found) { return $found }
return $expected # nothing found: return the expected path so the error names where we looked
}
Find-RepoBase checks the repo root and each immediate subfolder, prefers the one that also holds a solution file when there is more than one candidate, and returns $null rather than guess when it is genuinely ambiguous. An unusual layout resolves itself instead of becoming a special case, and a truly confusing one is reported, never guessed.
Order comes from the name
Deploy order is bottom-up: shared libraries and APIs first, then the per-feature workflow, then the app. Rather than store an order, derive a tier from the name.
function Get-ComponentTier {
param([string] $Name)
switch -Regex ($Name) {
'Lib$' { return 0 } # bundled class library, never deployed alone
'^DataApi$' { return 1 } # shared data-access API
'Security' { return 2 } # security APIs, shared and per-package
'Flow$' { return 3 } # workflow API
default { return 4 } # the front-end app
}
}
Tier 0 is a class library that gets bundled into the app at build time, so it has no deploy step of its own. Sort the final list by tier and the deploy order falls out for free.
The folder listing is the list
function Get-CandidateProjects {
param([string] $BaseDir)
if (-not (Test-Path -LiteralPath $BaseDir)) { return @() }
Get-ChildItem -LiteralPath $BaseDir -Directory | ForEach-Object {
$csproj = Join-Path $_.FullName "$($_.Name).csproj"
if ((Test-Path -LiteralPath $csproj) -and ($RetiredModules -notcontains $_.Name)) {
[pscustomobject]@{ Name = $_.Name; ProjectPath = $csproj }
}
}
}
Retiring a project needs more than the solution file
Here is a trap worth the whole post. Because discovery is a folder listing, removing a project from the .sln does not stop it building. The folder is still there, so it still gets picked up. A project we had retired kept getting built for exactly this reason.
The fix is an explicit exclusion list, and a loud one. Never drop a project silently, or “it did not build” gets confused with “it vanished.”
$RetiredModules = @('OldReportsFlow')
function Find-RetiredModules {
param([string] $BaseDir)
if (-not (Test-Path -LiteralPath $BaseDir)) { return @() }
return @(Get-ChildItem -LiteralPath $BaseDir -Directory -ErrorAction SilentlyContinue |
Where-Object {
$RetiredModules -contains $_.Name -and
(Test-Path -LiteralPath (Join-Path $_.FullName "$($_.Name).csproj"))
} |
ForEach-Object { $_.Name })
}
When a retired folder is found in a checkout, the run prints a clear note that it was deliberately not built. The lesson generalizes: if your discovery reads the disk, deleting from the solution is not enough. The folder has to go, or the name has to be excluded on purpose.
Finding the right publish profile
A project can hold several publish profiles, one per target. Pick by a filter, and treat more than one match as an error. Do not fall back to “the only profile present,” because publishing to a profile nobody asked for is exactly how output lands on the wrong server.
function Find-PublishProfile {
param([string] $ProjectPath)
$dir = Join-Path (Split-Path $ProjectPath -Parent) 'Properties\PublishProfiles'
if (-not (Test-Path -LiteralPath $dir)) { return $null }
$all = @(Get-ChildItem -LiteralPath $dir -Filter *.pubxml -File)
$match = @($all | Where-Object { $_.BaseName -like "*$ProfileFilter*" })
if ($match.Count -eq 1) { return $match[0].BaseName }
if ($match.Count -gt 1) {
throw "Ambiguous publish profile: $($match.Count) match '$ProfileFilter'. Narrow -ProfileFilter."
}
throw "No profile matches '$ProfileFilter' in $dir. Available: $(($all.BaseName) -join ', ')."
}
The destination itself comes from the profile, which is the single source of truth. Read the <PublishUrl> out of the .pubxml and strip the server prefix so what is left is the path under the target.
function Get-ProfileTargetPath {
param([string] $ProjectPath, [string] $ProfileName)
$file = Join-Path (Split-Path $ProjectPath -Parent) "Properties\PublishProfiles\$ProfileName.pubxml"
if (-not (Test-Path -LiteralPath $file)) { return $null }
try { [xml]$xml = Get-Content -LiteralPath $file -Raw } catch { return $null }
$url = @($xml.Project.PropertyGroup.PublishUrl) | Where-Object { $_ } | Select-Object -First 1
if (-not $url) { return $null }
if ($url -match '^\\\\[^\\]+\\(.+)$') { return $Matches[1] } # \\server\share\path -> share\path
return $null
}
Read the rest from the project, not a flag
Two more facts about a component are properties of the project, so read them from the project instead of keeping parallel flags that drift.
Whether it needs the licensed UI suite is just whether the .csproj references it:
function Test-NeedsLicense {
param([string] $ProjectPath)
if (-not (Test-Path -LiteralPath $ProjectPath)) { return $false }
return ((Get-Content -LiteralPath $ProjectPath -Raw) -match 'AcmeUI')
}
And which kind of server it belongs on can be read from the target path, since web apps and APIs land in different folders:
function Get-ServerRole {
param([string] $TargetPath)
if (-not $TargetPath) { return $null }
if ($TargetPath -match '(?i)\\WebSites\\') { return 'Web01' }
return 'App01'
}
Assemble and sort
Put it together: gather the shared components from the common repo and everything from the package repo, drop the tier-0 libraries, attach the profile, target, license need and server role, and sort by tier.
function Build-ComponentList {
$candidates = @()
$candidates += Get-CandidateProjects (Resolve-RepoBase 'common') |
Where-Object { $_.Name -in $SharedComponents }
$candidates += Get-CandidateProjects (Resolve-RepoBase $Repo)
$components = @()
foreach ($c in ($candidates | Sort-Object Name -Unique)) {
$tier = Get-ComponentTier $c.Name
if ($tier -eq 0) { continue }
$profileName = Find-PublishProfile $c.ProjectPath
$targetPath = if ($profileName) { Get-ProfileTargetPath $c.ProjectPath $profileName } else { $null }
$components += [pscustomobject]@{
Name = $c.Name
Tier = $tier
ProjectPath = $c.ProjectPath
PublishProfile = $profileName
NeedsLicense = (Test-NeedsLicense $c.ProjectPath)
TargetPath = $targetPath
ServerRole = (Get-ServerRole $targetPath)
}
}
return @($components | Sort-Object Tier, Name)
}
Adding a new component now means dropping a project into the repo that follows the naming convention. The script finds it, orders it and deploys it, with no edit here at all.
Next
Every component now carries a TargetPath and a ServerRole, but not yet an actual destination. Next time we turn those into a real place to publish, differently for each environment, and never by guessing.
Your turn
Do you discover your build units or list them by hand? If you have a naming convention that encodes deploy order, I would like to hear it. And has folder-listing discovery ever surprised you by building something you thought you had deleted? Comments are open.
// comments