Organizing Large Build Scripts
As your project grows, build scripts can become complex and difficult to maintain. This guide shows you how to organize large psake builds using modular task files, includes, shared utilities, and clear file structures.
Quick Start
Here's a basic modular build structure:
my-project/
├── build/
│ ├── tasks/
│ │ ├── build.ps1
│ │ ├── test.ps1
│ │ └── deploy.ps1
│ └── utils/
│ └── helpers.ps1
├── psakefile.ps1
└── build.ps1
Main psakefile.ps1:
Properties {
$ProjectRoot = $PSScriptRoot
$TasksDir = Join-Path $ProjectRoot 'build/tasks'
}
# Include modular task files
Include (Join-Path $TasksDir 'build.ps1')
Include (Join-Path $TasksDir 'test.ps1')
Include (Join-Path $TasksDir 'deploy.ps1')
Task Default -depends Build, Test
File Structure Patterns
Pattern 1: Tasks by Category
Organize tasks by functional area:
my-project/
├── build/
│ ├── tasks/
│ │ ├── compile.ps1 # Compilation tasks
│ │ ├── test.ps1 # Testing tasks
│ │ ├── package.ps1 # Packaging tasks
│ │ ├── deploy.ps1 # Deployment tasks
│ │ └── cleanup.ps1 # Cleanup tasks
│ ├── utils/
│ │ ├── fileops.ps1 # File operations
│ │ ├── versioning.ps1 # Version management
│ │ └── logging.ps1 # Custom logging
│ └── config/
│ ├── dev.ps1 # Development config
│ ├── staging.ps1 # Staging config
│ └── prod.ps1 # Production config
├── psakefile.ps1 # Main orchestrator
└── build.ps1 # Bootstrap script
psakefile.ps1:
Properties {
$ProjectRoot = $PSScriptRoot
$BuildRoot = Join-Path $ProjectRoot 'build'
$TasksDir = Join-Path $BuildRoot 'tasks'
$UtilsDir = Join-Path $BuildRoot 'utils'
$ConfigDir = Join-Path $BuildRoot 'config'
$Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' }
$Configuration = 'Release'
}
# Load utilities first (order matters)
Include (Join-Path $UtilsDir 'logging.ps1')
Include (Join-Path $UtilsDir 'fileops.ps1')
Include (Join-Path $UtilsDir 'versioning.ps1')
# Load environment-specific configuration
Include (Join-Path $ConfigDir "${Environment}.ps1")
# Load task modules
Include (Join-Path $TasksDir 'compile.ps1')
Include (Join-Path $TasksDir 'test.ps1')
Include (Join-Path $TasksDir 'package.ps1')
Include (Join-Path $TasksDir 'deploy.ps1')
Include (Join-Path $TasksDir 'cleanup.ps1')
FormatTaskName {
param($taskName)
Write-LogHeader "Executing: $taskName"
}
Task Default -depends Build
Task Build -depends Compile, Test, Package
Task CI -depends Build, Deploy
Task Full -depends Clean, Build, Deploy
Pattern 2: Tasks by Build Type
For projects with multiple build types (library, service, tools):
my-project/
├── build/
│ ├── tasks/
│ │ ├── library/
│ │ │ ├── build.ps1
│ │ │ ├── test.ps1
│ │ │ └── publish.ps1
│ │ ├── service/
│ │ │ ├── build.ps1
│ │ │ ├── docker.ps1
│ │ │ └── deploy.ps1
│ │ └── tools/
│ │ ├── build.ps1
│ │ └── package.ps1
│ └── shared/
│ └── common.ps1
└── psakefile.ps1
psakefile.ps1:
Properties {
$ProjectRoot = $PSScriptRoot
$BuildRoot = Join-Path $ProjectRoot 'build'
$BuildType = 'all' # Options: library, service, tools, all
}
# Load shared utilities
Include (Join-Path $BuildRoot 'shared/common.ps1')
# Conditionally load build type tasks
if ($BuildType -eq 'library' -or $BuildType -eq 'all') {
Include (Join-Path $BuildRoot 'tasks/library/build.ps1')
Include (Join-Path $BuildRoot 'tasks/library/test.ps1')
Include (Join-Path $BuildRoot 'tasks/library/publish.ps1')
}
if ($BuildType -eq 'service' -or $BuildType -eq 'all') {
Include (Join-Path $BuildRoot 'tasks/service/build.ps1')
Include (Join-Path $BuildRoot 'tasks/service/docker.ps1')
Include (Join-Path $BuildRoot 'tasks/service/deploy.ps1')
}
if ($BuildType -eq 'tools' -or $BuildType -eq 'all') {
Include (Join-Path $BuildRoot 'tasks/tools/build.ps1')
Include (Join-Path $BuildRoot 'tasks/tools/package.ps1')
}
Task Default -depends Build
Task Build {
if ($BuildType -eq 'library' -or $BuildType -eq 'all') {
Invoke-psake -taskList Library:Build
}
if ($BuildType -eq 'service' -or $BuildType -eq 'all') {
Invoke-psake -taskList Service:Build
}
if ($BuildType -eq 'tools' -or $BuildType -eq 'all') {
Invoke-psake -taskList Tools:Build
}
}
Modular Task Files
Break down complex builds into focused, reusable task files.
Example: Compilation Tasks
build/tasks/compile.ps1:
Properties {
# These can reference properties from main psakefile
$SrcDir = Join-Path $ProjectRoot 'src'
$BuildDir = Join-Path $ProjectRoot 'build/output'
}
Task Compile -depends Clean {
Write-Host "Compiling solution..." -ForegroundColor Green
$solutionFile = Get-ChildItem "$SrcDir/*.sln" | Select-Object -First 1
if (-not $solutionFile) {
throw "No solution file found in $SrcDir"
}
exec {
dotnet build $solutionFile.FullName `
-c $Configuration `
-o $BuildDir `
/p:Version=$Version `
--no-incremental
}
Write-Host "Compilation complete: $BuildDir" -ForegroundColor Green
}
Task CompileDebug {
$script:Configuration = 'Debug'
Invoke-psake -taskList Compile
}
Task CompileRelease {
$script:Configuration = 'Release'
Invoke-psake -taskList Compile
}
Task Restore {
Write-Host "Restoring NuGet packages..." -ForegroundColor Green
$solutionFile = Get-ChildItem "$SrcDir/*.sln" | Select-Object -First 1
exec { dotnet restore $solutionFile.FullName }
}
Task Clean {
Write-Host "Cleaning build artifacts..." -ForegroundColor Green
if (Test-Path $BuildDir) {
Remove-Item $BuildDir -Recurse -Force
Write-Host " Removed: $BuildDir" -ForegroundColor Gray
}
# Clean obj and bin directories
Get-ChildItem $SrcDir -Include bin,obj -Recurse -Directory | ForEach-Object {
Remove-Item $_.FullName -Recurse -Force
Write-Host " Removed: $($_.FullName)" -ForegroundColor Gray
}
}
Example: Testing Tasks
build/tasks/test.ps1:
Properties {
$TestDir = Join-Path $ProjectRoot 'tests'
$TestResultsDir = Join-Path $ProjectRoot 'TestResults'
$CoverageThreshold = 80
}
Task Test -depends Compile {
Write-Host "Running unit tests..." -ForegroundColor Green
if (-not (Test-Path $TestDir)) {
Write-Warning "No tests directory found at $TestDir"
return
}
exec {
dotnet test $TestDir `
--configuration $Configuration `
--no-build `
--logger "trx;LogFileName=test-results.trx" `
--results-directory $TestResultsDir
}
}
Task TestWithCoverage -depends Compile {
Write-Host "Running tests with coverage..." -ForegroundColor Green
exec {
dotnet test $TestDir `
--configuration $Configuration `
--no-build `
--collect:"XPlat Code Coverage" `
--results-directory $TestResultsDir
}
# Check coverage threshold
$coverageFile = Get-ChildItem "$TestResultsDir/**/coverage.cobertura.xml" -Recurse | Select-Object -First 1
if ($coverageFile) {
[xml]$coverage = Get-Content $coverageFile.FullName
$lineRate = [double]$coverage.coverage.'line-rate' * 100
Write-Host "Code coverage: ${lineRate}%" -ForegroundColor Cyan
if ($lineRate -lt $CoverageThreshold) {
throw "Coverage ${lineRate}% is below threshold ${CoverageThreshold}%"
}
}
}
Task TestUnit {
exec {
dotnet test $TestDir `
--filter "Category=Unit" `
--configuration $Configuration
}
}
Task TestIntegration -depends Build {
exec {
dotnet test $TestDir `
--filter "Category=Integration" `
--configuration $Configuration
}
}
Example: Deployment Tasks
build/tasks/deploy.ps1:
Properties {
$DeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'dev' }
$DeploymentDir = Join-Path $ProjectRoot 'deployment'
}
Task Deploy -depends Package -precondition { $Environment -ne 'dev' } {
Write-Host "Deploying to $DeployTarget..." -ForegroundColor Green
switch ($DeployTarget) {
'azure' { Invoke-psake -taskList Deploy:Azure }
'aws' { Invoke-psake -taskList Deploy:AWS }
'local' { Invoke-psake -taskList Deploy:Local }
default { throw "Unknown deploy target: $DeployTarget" }
}
}
Task Deploy:Azure {
Write-Host "Deploying to Azure..." -ForegroundColor Green
$webAppName = $AzureWebAppName
$resourceGroup = $AzureResourceGroup
if ([string]::IsNullOrEmpty($webAppName) -or [string]::IsNullOrEmpty($resourceGroup)) {
throw "Azure configuration is incomplete"
}
$packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1
exec {
az webapp deployment source config-zip `
--resource-group $resourceGroup `
--name $webAppName `
--src $packageFile.FullName
}
Write-Host "Deployed to Azure: https://${webAppName}.azurewebsites.net" -ForegroundColor Green
}
Task Deploy:AWS {
Write-Host "Deploying to AWS..." -ForegroundColor Green
# AWS deployment logic here
throw "AWS deployment not yet implemented"
}
Task Deploy:Local {
Write-Host "Deploying to local environment..." -ForegroundColor Green
$targetDir = Join-Path $DeploymentDir $Environment
if (Test-Path $targetDir) {
Remove-Item $targetDir -Recurse -Force
}
Copy-Item $BuildDir -Destination $targetDir -Recurse
Write-Host "Deployed to: $targetDir" -ForegroundColor Green
}
Using Include Effectively
The Include function allows you to split build logic across multiple files.
Include with Path Validation
Properties {
$BuildRoot = Join-Path $PSScriptRoot 'build'
}
# Helper function to safely include files
function Include-TaskFile {
param([string]$RelativePath)
$fullPath = Join-Path $BuildRoot $RelativePath
if (-not (Test-Path $fullPath)) {
throw "Task file not found: $fullPath"
}
Include $fullPath
}
# Include task files with validation
Include-TaskFile 'tasks/build.ps1'
Include-TaskFile 'tasks/test.ps1'
Include-TaskFile 'tasks/deploy.ps1'
Dynamic Includes Based on Configuration
Properties {
$ProjectType = 'dotnet' # Options: dotnet, nodejs, docker
$TasksDir = Join-Path $PSScriptRoot 'build/tasks'
}
# Include common tasks
Include (Join-Path $TasksDir 'common.ps1')
# Include project-type specific tasks
$projectTaskFile = Join-Path $TasksDir "${ProjectType}.ps1"
if (Test-Path $projectTaskFile) {
Include $projectTaskFile
} else {
throw "No task file found for project type: $ProjectType"
}
# Include optional tasks if they exist
$optionalTasks = @('docker.ps1', 'kubernetes.ps1', 'terraform.ps1')
foreach ($taskFile in $optionalTasks) {
$fullPath = Join-Path $TasksDir $taskFile
if (Test-Path $fullPath) {
Write-Host "Loading optional tasks: $taskFile" -ForegroundColor Gray
Include $fullPath
}
}
Include Order Matters
# 1. Include utilities first (they define helper functions)
Include (Join-Path $BuildRoot 'utils/logging.ps1')
Include (Join-Path $BuildRoot 'utils/helpers.ps1')
# 2. Include configuration (depends on utilities)
Include (Join-Path $BuildRoot 'config/settings.ps1')
# 3. Include tasks (depend on utilities and config)
Include (Join-Path $BuildRoot 'tasks/build.ps1')
Include (Join-Path $BuildRoot 'tasks/test.ps1')
Include (Join-Path $BuildRoot 'tasks/deploy.ps1')
Shared Utilities
Create reusable utility functions that can be shared across all task files.
Example: File Operations Utility
build/utils/fileops.ps1:
# File operation utilities
function Remove-DirectorySafe {
param(
[string]$Path,
[switch]$Quiet
)
if (Test-Path $Path) {
Remove-Item $Path -Recurse -Force
if (-not $Quiet) {
Write-Host " Removed: $Path" -ForegroundColor Gray
}
return $true
}
return $false
}
function New-DirectorySafe {
param(
[string]$Path,
[switch]$Quiet
)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
if (-not $Quiet) {
Write-Host " Created: $Path" -ForegroundColor Gray
}
return $true
}
return $false
}
function Copy-DirectoryContents {
param(
[string]$Source,
[string]$Destination,
[string[]]$Exclude = @()
)
if (-not (Test-Path $Source)) {
throw "Source directory not found: $Source"
}
New-DirectorySafe -Path $Destination -Quiet
$items = Get-ChildItem $Source -Recurse
foreach ($item in $items) {
$skip = $false
foreach ($pattern in $Exclude) {
if ($item.FullName -like "*$pattern*") {
$skip = $true
break
}
}
if ($skip) { continue }
$relativePath = $item.FullName.Substring($Source.Length)
$targetPath = Join-Path $Destination $relativePath
if ($item.PSIsContainer) {
New-DirectorySafe -Path $targetPath -Quiet
} else {
Copy-Item $item.FullName -Destination $targetPath -Force
}
}
}
function Get-FileHash256 {
param([string]$FilePath)
if (-not (Test-Path $FilePath)) {
throw "File not found: $FilePath"
}
return (Get-FileHash -Path $FilePath -Algorithm SHA256).Hash
}
# Export utilities (make them available to other scripts)
Export-ModuleMember -Function @(
'Remove-DirectorySafe',
'New-DirectorySafe',
'Copy-DirectoryContents',
'Get-FileHash256'
)
Example: Logging Utility
build/utils/logging.ps1:
# Logging utilities
function Write-LogHeader {
param([string]$Message)
$separator = "=" * 80
Write-Host $separator -ForegroundColor Cyan
Write-Host " $Message" -ForegroundColor Cyan
Write-Host $separator -ForegroundColor Cyan
}
function Write-LogSection {
param([string]$Message)
Write-Host ""
Write-Host ">>> $Message" -ForegroundColor Green
}
function Write-LogInfo {
param([string]$Message)
Write-Host " [INFO] $Message" -ForegroundColor Gray
}
function Write-LogSuccess {
param([string]$Message)
Write-Host " [SUCCESS] $Message" -ForegroundColor Green
}
function Write-LogWarning {
param([string]$Message)
Write-Host " [WARNING] $Message" -ForegroundColor Yellow
}
function Write-LogError {
param([string]$Message)
Write-Host " [ERROR] $Message" -ForegroundColor Red
}
function Write-LogStep {
param(
[int]$Step,
[int]$Total,
[string]$Message
)
Write-Host " [$Step/$Total] $Message" -ForegroundColor Cyan
}
# Export utilities
Export-ModuleMember -Function @(
'Write-LogHeader',
'Write-LogSection',
'Write-LogInfo',
'Write-LogSuccess',
'Write-LogWarning',
'Write-LogError',
'Write-LogStep'
)
Example: Versioning Utility
build/utils/versioning.ps1:
# Version management utilities
function Get-GitVersion {
<#
.SYNOPSIS
Gets version information from git tags and commits
#>
try {
# Get latest tag
$tag = git describe --tags --abbrev=0 2>$null
if ([string]::IsNullOrEmpty($tag)) {
return "1.0.0"
}
# Parse semantic version
if ($tag -match '^v?(\d+)\.(\d+)\.(\d+)') {
$major = $matches[1]
$minor = $matches[2]
$patch = $matches[3]
# Get commits since tag
$commitsSinceTag = git rev-list "$tag..HEAD" --count 2>$null
if ($commitsSinceTag -gt 0) {
# Bump patch version
$patch = [int]$patch + 1
return "$major.$minor.$patch-dev.$commitsSinceTag"
}
return "$major.$minor.$patch"
}
return "1.0.0"
}
catch {
Write-Warning "Failed to get git version: $_"
return "1.0.0"
}
}
function Get-BuildVersion {
param(
[string]$BaseVersion = "1.0.0",
[string]$BuildNumber = $null
)
if ([string]::IsNullOrEmpty($BuildNumber)) {
$BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { "0" }
}
if ($BaseVersion -match '^(\d+)\.(\d+)\.(\d+)') {
$major = $matches[1]
$minor = $matches[2]
return "$major.$minor.$BuildNumber"
}
return "$BaseVersion.$BuildNumber"
}
function Set-AssemblyVersion {
param(
[string]$ProjectFile,
[string]$Version
)
if (-not (Test-Path $ProjectFile)) {
throw "Project file not found: $ProjectFile"
}
[xml]$project = Get-Content $ProjectFile
$propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1
if ($null -eq $propertyGroup.Version) {
$versionNode = $project.CreateElement("Version")
$propertyGroup.AppendChild($versionNode) | Out-Null
}
$propertyGroup.Version = $Version
$project.Save($ProjectFile)
Write-Host "Updated version to $Version in $ProjectFile" -ForegroundColor Green
}
Export-ModuleMember -Function @(
'Get-GitVersion',
'Get-BuildVersion',
'Set-AssemblyVersion'
)
Complete Example: Large Project
Here's a complete example combining all patterns:
psakefile.ps1:
Properties {
# Base paths
$ProjectRoot = $PSScriptRoot
$BuildRoot = Join-Path $ProjectRoot 'build'
$SrcDir = Join-Path $ProjectRoot 'src'
$TestDir = Join-Path $ProjectRoot 'tests'
$BuildDir = Join-Path $ProjectRoot 'build/output'
# Configuration
$Configuration = if ($env:BUILD_CONFIGURATION) { $env:BUILD_CONFIGURATION } else { 'Debug' }
$Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' }
# Versioning
$Version = Get-GitVersion
$BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' }
}
# Load utilities (order matters!)
Include (Join-Path $BuildRoot 'utils/logging.ps1')
Include (Join-Path $BuildRoot 'utils/fileops.ps1')
Include (Join-Path $BuildRoot 'utils/versioning.ps1')
# Load environment configuration
$envConfig = Join-Path $BuildRoot "config/${Environment}.ps1"
if (Test-Path $envConfig) {
Include $envConfig
}
# Load task modules
Include (Join-Path $BuildRoot 'tasks/compile.ps1')
Include (Join-Path $BuildRoot 'tasks/test.ps1')
Include (Join-Path $BuildRoot 'tasks/package.ps1')
Include (Join-Path $BuildRoot 'tasks/deploy.ps1')
Include (Join-Path $BuildRoot 'tasks/cleanup.ps1')
# Custom task formatter
FormatTaskName {
param($taskName)
Write-LogHeader "Task: $taskName"
}
# Main orchestration tasks
Task Default -depends Build
Task Build -depends Restore, Compile, Test {
Write-LogSuccess "Build completed successfully"
}
Task CI -depends Build, Package {
Write-LogSuccess "CI build completed"
}
Task Release -depends Clean, Build, Package, Deploy {
Write-LogSuccess "Release completed"
}
Task Full -depends Clean, Restore, Compile, TestWithCoverage, Package, Deploy {
Write-LogSuccess "Full build and deployment completed"
}
Best Practices Summary
- Use a clear directory structure - Organize by category or build type
- Keep task files focused - One responsibility per file
- Load utilities before tasks - Ensure dependencies are available
- Use Include for modularization - Split large builds into manageable pieces
- Create shared utilities - Avoid duplicating code across task files
- Validate file paths - Check that included files exist
- Use meaningful names - Make task files and functions self-documenting
- Document complex logic - Add comments explaining non-obvious decisions
- Keep the main psakefile simple - It should orchestrate, not implement
- Test modular components - Ensure each task file works independently
See Also
- Access Functions in Another File - Using Include and dot-sourcing
- Structure of a psake Build Script - Basic script structure
- Environment Management - Managing multiple environments
- Testing Build Scripts - Testing your psake scripts
- .NET Solution Builds - Complete .NET examples