diff --git a/docs/best-practices/_category_.json b/docs/best-practices/_category_.json new file mode 100644 index 0000000..cb3b64d --- /dev/null +++ b/docs/best-practices/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Best Practices", + "position": 4, + "link": { + "type": "generated-index", + "description": "Best practices for organizing, testing, and maintaining psake build scripts in production environments." + } +} diff --git a/docs/best-practices/environment-management.md b/docs/best-practices/environment-management.md new file mode 100644 index 0000000..8c20b33 --- /dev/null +++ b/docs/best-practices/environment-management.md @@ -0,0 +1,911 @@ +--- +title: "Environment Management" +description: "Manage multiple deployment environments in psake using environment-specific properties, configuration files, and conditional task execution" +--- + +# Environment Management + +Managing multiple environments (development, staging, production) is crucial for reliable software delivery. This guide shows you how to configure psake builds for different environments using properties, configuration files, and conditional task execution. + +## Quick Start + +Here's a basic environment-aware build: + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $Configuration = if ($Environment -eq 'prod') { 'Release' } else { 'Debug' } + + # Environment-specific settings + $ApiUrl = switch ($Environment) { + 'dev' { 'https://api.dev.example.com' } + 'staging' { 'https://api.staging.example.com' } + 'prod' { 'https://api.example.com' } + } +} + +Task Build { + Write-Host "Building for environment: $Environment" -ForegroundColor Green + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + Write-Host " API URL: $ApiUrl" -ForegroundColor Gray + + exec { dotnet build -c $Configuration /p:ApiUrl=$ApiUrl } +} +``` + +Run for different environments: + +```powershell +# Development (default) +Invoke-psake + +# Staging +$env:BUILD_ENV = 'staging' +Invoke-psake + +# Production +$env:BUILD_ENV = 'prod' +Invoke-psake +``` + +## Environment Configuration Patterns + +### Pattern 1: Inline Environment Properties + +Simple projects with few environment differences: + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $environmentSettings = @{ + dev = @{ + Configuration = 'Debug' + DatabaseServer = 'localhost' + ApiUrl = 'http://localhost:5000' + EnableTelemetry = $false + LogLevel = 'Debug' + } + staging = @{ + Configuration = 'Release' + DatabaseServer = 'db-staging.internal' + ApiUrl = 'https://api-staging.example.com' + EnableTelemetry = $true + LogLevel = 'Information' + } + prod = @{ + Configuration = 'Release' + DatabaseServer = 'db-prod.internal' + ApiUrl = 'https://api.example.com' + EnableTelemetry = $true + LogLevel = 'Warning' + } + } + + if (-not $environmentSettings.ContainsKey($Environment)) { + throw "Unknown environment: $Environment" + } + + $settings = $environmentSettings[$Environment] + $Configuration = $settings.Configuration + $DatabaseServer = $settings.DatabaseServer + $ApiUrl = $settings.ApiUrl + $EnableTelemetry = $settings.EnableTelemetry + $LogLevel = $settings.LogLevel +} + +Task Build { + Write-Host "Building for: $Environment" -ForegroundColor Cyan + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + Write-Host " Database: $DatabaseServer" -ForegroundColor Gray + Write-Host " API: $ApiUrl" -ForegroundColor Gray + Write-Host " Telemetry: $EnableTelemetry" -ForegroundColor Gray + Write-Host " Log Level: $LogLevel" -ForegroundColor Gray + + exec { + dotnet build -c $Configuration ` + /p:DatabaseServer=$DatabaseServer ` + /p:ApiUrl=$ApiUrl ` + /p:EnableTelemetry=$EnableTelemetry ` + /p:LogLevel=$LogLevel + } +} +``` + +### Pattern 2: External Configuration Files + +For complex projects with many environment-specific settings: + +``` +my-project/ +├── build/ +│ └── config/ +│ ├── dev.ps1 +│ ├── staging.ps1 +│ └── prod.ps1 +└── psakefile.ps1 +``` + +**build/config/dev.ps1:** + +```powershell +# Development environment configuration + +Properties { + # Build settings + $Configuration = 'Debug' + $Platform = 'AnyCPU' + $SkipTests = $false + + # Infrastructure + $DatabaseServer = 'localhost' + $DatabaseName = 'MyApp_Dev' + $RedisServer = 'localhost:6379' + + # API endpoints + $ApiBaseUrl = 'http://localhost:5000' + $AuthServiceUrl = 'http://localhost:5001' + + # Feature flags + $EnableCaching = $false + $EnableTelemetry = $false + $EnableAuthentication = $false + + # Logging + $LogLevel = 'Debug' + $LogToFile = $true + $LogToConsole = $true + + # Deployment + $DeploymentTarget = 'local' + $SkipHealthChecks = $true +} +``` + +**build/config/staging.ps1:** + +```powershell +# Staging environment configuration + +Properties { + # Build settings + $Configuration = 'Release' + $Platform = 'AnyCPU' + $SkipTests = $false + + # Infrastructure + $DatabaseServer = 'db-staging.internal.example.com' + $DatabaseName = 'MyApp_Staging' + $RedisServer = 'redis-staging.internal.example.com:6379' + + # API endpoints + $ApiBaseUrl = 'https://api-staging.example.com' + $AuthServiceUrl = 'https://auth-staging.example.com' + + # Feature flags + $EnableCaching = $true + $EnableTelemetry = $true + $EnableAuthentication = $true + + # Logging + $LogLevel = 'Information' + $LogToFile = $true + $LogToConsole = $false + + # Deployment + $DeploymentTarget = 'azure-staging' + $SkipHealthChecks = $false + $AzureResourceGroup = 'rg-myapp-staging' + $AzureWebAppName = 'myapp-staging' +} +``` + +**build/config/prod.ps1:** + +```powershell +# Production environment configuration + +Properties { + # Build settings + $Configuration = 'Release' + $Platform = 'AnyCPU' + $SkipTests = $false + + # Infrastructure + $DatabaseServer = 'db-prod.internal.example.com' + $DatabaseName = 'MyApp_Production' + $RedisServer = 'redis-prod.internal.example.com:6379' + + # API endpoints + $ApiBaseUrl = 'https://api.example.com' + $AuthServiceUrl = 'https://auth.example.com' + + # Feature flags + $EnableCaching = $true + $EnableTelemetry = $true + $EnableAuthentication = $true + + # Logging + $LogLevel = 'Warning' + $LogToFile = $true + $LogToConsole = $false + + # Deployment + $DeploymentTarget = 'azure-production' + $SkipHealthChecks = $false + $AzureResourceGroup = 'rg-myapp-prod' + $AzureWebAppName = 'myapp-prod' + $RequireApproval = $true +} +``` + +**psakefile.ps1:** + +```powershell +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$configDirectory = Join-Path $PSScriptRoot 'build/config' +$environmentConfig = Join-Path $configDirectory "${selectedEnvironment}.ps1" + +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig. Valid environments: dev, staging, prod" +} + +Write-Host "Loading configuration for: $selectedEnvironment" -ForegroundColor Cyan +Include $environmentConfig + +Properties { + $ProjectRoot = $PSScriptRoot + $BuildDir = Join-Path $ProjectRoot 'build/output' + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $ConfigDir = Join-Path $ProjectRoot 'build/config' +} + +Task Default -depends Build + +Task Build { + Write-Host "Building for $Environment environment..." -ForegroundColor Green + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + Write-Host " Database: $DatabaseServer/$DatabaseName" -ForegroundColor Gray + Write-Host " API: $ApiBaseUrl" -ForegroundColor Gray + + exec { dotnet build -c $Configuration } +} + +function Deploy-AzureWebApp { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile.FullName ` + --type zip + } +} + +Task Deploy -depends Build { + if ($RequireApproval -and $env:DEPLOY_APPROVED -ne 'true') { + throw "Deployment to $Environment requires DEPLOY_APPROVED=true" + } + + switch ($DeploymentTarget) { + 'local' { Copy-Item "$BuildDir/*" -Destination "C:\Deploy\$Environment" -Recurse -Force } + 'azure-staging' { Deploy-AzureWebApp } + 'azure-production' { Deploy-AzureWebApp } + default { throw "Unknown deployment target: $DeploymentTarget" } + } +} +``` + +### Pattern 3: JSON/YAML Configuration Files + +Use structured configuration files for complex settings: + +**build/config/environments.json:** + +```json +{ + "dev": { + "configuration": "Debug", + "database": { + "server": "localhost", + "name": "MyApp_Dev", + "port": 5432 + }, + "services": { + "api": "http://localhost:5000", + "auth": "http://localhost:5001" + }, + "features": { + "caching": false, + "telemetry": false + } + }, + "staging": { + "configuration": "Release", + "database": { + "server": "db-staging.internal.example.com", + "name": "MyApp_Staging", + "port": 5432 + }, + "services": { + "api": "https://api-staging.example.com", + "auth": "https://auth-staging.example.com" + }, + "features": { + "caching": true, + "telemetry": true + } + }, + "prod": { + "configuration": "Release", + "database": { + "server": "db-prod.internal.example.com", + "name": "MyApp_Production", + "port": 5432 + }, + "services": { + "api": "https://api.example.com", + "auth": "https://auth.example.com" + }, + "features": { + "caching": true, + "telemetry": true + } + } +} +``` + +**build/config/environments.yaml:** + +```yaml +dev: + configuration: Debug + services: + api: http://localhost:5000 +staging: + configuration: Release + services: + api: https://api-staging.example.com +prod: + configuration: Release + services: + api: https://api.example.com +``` + +For YAML, install and import `powershell-yaml`, then replace the JSON-loading expression with: + +```powershell +Import-Module powershell-yaml +$configurationFile = Join-Path $PSScriptRoot 'build/config/environments.yaml' +$allConfigs = Get-Content $configurationFile -Raw | ConvertFrom-Yaml +``` + +**psakefile.ps1:** + +```powershell +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$configurationFile = Join-Path $PSScriptRoot 'build/config/environments.json' + +if (-not (Test-Path $configurationFile)) { + throw "Configuration file not found: $configurationFile" +} + +$allConfigs = Get-Content $configurationFile -Raw | ConvertFrom-Json +$selectedConfig = $allConfigs.$selectedEnvironment + +if ($null -eq $selectedConfig) { + throw "Configuration for environment '$selectedEnvironment' not found in $configurationFile" +} + +Properties { + $ProjectRoot = $PSScriptRoot + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $ConfigFile = Join-Path $ProjectRoot 'build/config/environments.json' + $Configuration = $selectedConfig.configuration + $DatabaseServer = $selectedConfig.database.server + $DatabaseName = $selectedConfig.database.name + $DatabasePort = $selectedConfig.database.port + $ApiUrl = $selectedConfig.services.api + $AuthUrl = $selectedConfig.services.auth + $EnableCaching = $selectedConfig.features.caching + $EnableTelemetry = $selectedConfig.features.telemetry +} + +Task Build { + Write-Host "Building with configuration from: $ConfigFile" -ForegroundColor Green + Write-Host " Environment: $Environment" -ForegroundColor Cyan + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + Write-Host " Database: ${DatabaseServer}:${DatabasePort}/${DatabaseName}" -ForegroundColor Gray + Write-Host " API: $ApiUrl" -ForegroundColor Gray + + # Generate configuration file for application + $appConfig = @{ + ConnectionStrings = @{ + DefaultConnection = "Server=$DatabaseServer;Port=$DatabasePort;Database=$DatabaseName;" + } + Services = @{ + ApiBaseUrl = $ApiUrl + AuthServiceUrl = $AuthUrl + } + Features = @{ + EnableCaching = $EnableCaching + EnableTelemetry = $EnableTelemetry + } + } + + $appConfigPath = Join-Path $ProjectRoot "src/appsettings.$Environment.json" + $appConfig | ConvertTo-Json -Depth 10 | Set-Content $appConfigPath + + exec { dotnet build -c $Configuration } +} +``` + +## Conditional Task Execution + +Execute tasks based on environment: + +### Using Preconditions + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +} + +Task RunTests { + exec { dotnet test } +} + +Task DeployToStaging -depends Build -precondition { $Environment -eq 'staging' } { + Write-Host "Deploying to staging..." -ForegroundColor Green + # Staging deployment logic +} + +Task DeployToProduction -depends Build -precondition { $Environment -eq 'prod' } { + Write-Host "Deploying to production..." -ForegroundColor Green + # Production deployment logic + + # Additional production-only verification + exec { dotnet test --filter Category=Smoke } +} + +Task TestOutsideDevelopment -precondition { $Environment -ne 'dev' } { + exec { dotnet test } +} +``` + +### Environment-Specific Task Lists + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +} + + +Task Build -depends Clean, Compile + +Task Dev -depends Build, RunDevServer { + Write-Host "Development build complete" -ForegroundColor Green +} + +Task Staging -depends Build, RunTests, Package, DeployStaging { + Write-Host "Staging deployment complete" -ForegroundColor Green +} + +Task Production -depends Build, RunAllTests, SecurityScan, Package, DeployProduction { + Write-Host "Production deployment complete" -ForegroundColor Green +} + +$environmentTasks = @{ + dev = 'Dev' + staging = 'Staging' + prod = 'Production' +} +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +if (-not $environmentTasks.ContainsKey($selectedEnvironment)) { + throw "Unknown environment: $selectedEnvironment" +} + +Task Default -depends $environmentTasks[$selectedEnvironment] +``` + +### Conditional Build Steps + +```powershell +Task Build { + # Always compile + exec { dotnet build -c $Configuration } + + # Environment-specific build steps + if ($Environment -eq 'prod') { + Write-Host "Running production-specific optimizations..." -ForegroundColor Cyan + + # Minify JavaScript/CSS + exec { npm run minify } + + # Optimize images + exec { npm run optimize-images } + + # Generate source maps + exec { npm run sourcemaps } + } + + if ($Environment -ne 'dev') { + Write-Host "Running AOT compilation..." -ForegroundColor Cyan + exec { dotnet publish -c $Configuration /p:PublishAot=true } + } + + if ($EnableTelemetry) { + $env:OTEL_SERVICE_NAME = 'MyApp' + $env:OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=$Environment" + Write-Host "Configured OpenTelemetry resource attributes" -ForegroundColor Cyan + } +} +``` + +## Complete Environment Management Example + +Here's a comprehensive example combining all patterns: + +**psakefile.ps1:** + +```powershell +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$validEnvironments = @('dev', 'staging', 'prod') +if ($selectedEnvironment -notin $validEnvironments) { + throw "Invalid environment: $selectedEnvironment. Valid options: $($validEnvironments -join ', ')" +} + +$configDirectory = Join-Path $PSScriptRoot 'build/config' +$environmentConfig = Join-Path $configDirectory "${selectedEnvironment}.ps1" +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig" +} + +Write-Host "Loading environment configuration: $selectedEnvironment" -ForegroundColor Cyan +Include $environmentConfig + +Properties { + $ProjectRoot = $PSScriptRoot + $SrcDir = Join-Path $ProjectRoot 'src' + $BuildDir = Join-Path $ProjectRoot 'build/output' + $ConfigDir = Join-Path $ProjectRoot 'build/config' + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +} + +FormatTaskName { + param($taskName) + Write-Host "" + Write-Host "[$Environment] Executing: $taskName" -ForegroundColor Cyan + Write-Host ("=" * 80) -ForegroundColor Gray +} + +Task Default -depends Build + +Task Clean { + Write-Host "Cleaning build artifacts..." -ForegroundColor Green + + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + + New-Item -ItemType Directory -Path $BuildDir | Out-Null +} + +Task Compile -depends Clean { + Write-Host "Compiling for $Environment..." -ForegroundColor Green + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + + exec { + dotnet build $SrcDir ` + -c $Configuration ` + -o $BuildDir ` + /p:Environment=$Environment + } +} + +Task Test -depends Compile -precondition { -not $SkipTests } { + Write-Host "Running tests..." -ForegroundColor Green + + exec { + dotnet test $SrcDir ` + --configuration $Configuration ` + --no-build + } +} + +Task IntegrationTests -depends Test -precondition { $Environment -ne 'dev' } { + Write-Host "Running integration tests..." -ForegroundColor Green + + exec { + dotnet test $SrcDir ` + --filter "Category=Integration" ` + --configuration $Configuration + } +} + +Task SecurityScan -depends Compile -precondition { $Environment -eq 'prod' } { + Write-Host "Running security scan..." -ForegroundColor Green + + # Run security scanning tools + exec { dotnet tool run security-scan } +} + +Task Package -depends Test { + Write-Host "Creating deployment package..." -ForegroundColor Green + + $packageName = "MyApp-${Environment}-$(Get-Date -Format 'yyyyMMdd-HHmmss').zip" + $packagePath = Join-Path $BuildDir $packageName + + Compress-Archive -Path "$BuildDir/*" -DestinationPath $packagePath + + Write-Host "Package created: $packagePath" -ForegroundColor Green +} + +function Test-DeploymentHealth { + if ($SkipHealthChecks) { return } + + $response = Invoke-WebRequest -Uri "$ApiBaseUrl/health" -TimeoutSec 30 + if ($response.StatusCode -ne 200) { + throw "Health check failed with status: $($response.StatusCode)" + } + Write-Host " Health check passed" -ForegroundColor Green +} + +Task Deploy -depends Package { + if ($RequireApproval -and $env:DEPLOY_APPROVED -ne 'true') { + throw "Deployment to $Environment requires DEPLOY_APPROVED=true" + } + + Write-Host "Deploying to $DeploymentTarget..." -ForegroundColor Green + + switch ($DeploymentTarget) { + 'local' { + Copy-Item "$BuildDir/*" -Destination "C:\Deploy\$Environment" -Recurse -Force + } + { $_ -in 'azure-staging', 'azure-production' } { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile.FullName ` + --type zip + } + + if ($DeploymentTarget -eq 'azure-production') { + Start-Sleep -Seconds 10 + Test-DeploymentHealth + } + } + default { throw "Unknown deployment target: $DeploymentTarget" } + } + + Write-Host "Deployment to $Environment complete!" -ForegroundColor Green +} + +Task HealthCheck -precondition { -not $SkipHealthChecks } { + Write-Host "Running health checks..." -ForegroundColor Green + + $healthUrl = "$ApiBaseUrl/health" + + try { + $response = Invoke-WebRequest -Uri $healthUrl -TimeoutSec 30 + if ($response.StatusCode -eq 200) { + Write-Host " Health check passed" -ForegroundColor Green + } else { + throw "Health check failed with status: $($response.StatusCode)" + } + } + catch { + throw "Health check failed: $_" + } +} + +Task ShowConfig { + Write-Host "" + Write-Host "Current Environment Configuration" -ForegroundColor Cyan + Write-Host ("=" * 80) -ForegroundColor Gray + Write-Host " Environment: $Environment" -ForegroundColor White + Write-Host " Configuration: $Configuration" -ForegroundColor Gray + Write-Host " Database: $DatabaseServer/$DatabaseName" -ForegroundColor Gray + Write-Host " API Base URL: $ApiBaseUrl" -ForegroundColor Gray + Write-Host " Auth Service: $AuthServiceUrl" -ForegroundColor Gray + Write-Host " Enable Caching: $EnableCaching" -ForegroundColor Gray + Write-Host " Enable Telemetry: $EnableTelemetry" -ForegroundColor Gray + Write-Host " Log Level: $LogLevel" -ForegroundColor Gray + Write-Host " Deployment Target: $DeploymentTarget" -ForegroundColor Gray + Write-Host " Skip Tests: $SkipTests" -ForegroundColor Gray + Write-Host ("=" * 80) -ForegroundColor Gray + Write-Host "" +} +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Multi-Environment Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + build-dev: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install psake + shell: pwsh + run: Install-Module -Name psake -Force + + - name: Build for Development + shell: pwsh + run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Build + env: + BUILD_ENV: dev + + build-staging: + runs-on: windows-latest + if: github.ref == 'refs/heads/develop' + steps: + - uses: actions/checkout@v4 + + - name: Install psake + shell: pwsh + run: Install-Module -Name psake -Force + + - name: Sign in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS_STAGING }} + + - name: Build and Deploy to Staging + shell: pwsh + run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy + env: + BUILD_ENV: staging + DEPLOY_APPROVED: 'true' + build-production: + runs-on: windows-latest + if: github.ref == 'refs/heads/main' + environment: production + steps: + - uses: actions/checkout@v4 + + - name: Install psake + shell: pwsh + run: Install-Module -Name psake -Force + + - name: Sign in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS_PROD }} + + - name: Build and Deploy to Production + shell: pwsh + run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy + env: + BUILD_ENV: prod + DEPLOY_APPROVED: 'true' +``` + +### Azure Pipelines + +Use an Azure Resource Manager service connection so credentials never enter the build script: + +```yaml +trigger: + branches: + include: + - main + - develop + +stages: + - stage: Build + jobs: + - job: Build + pool: + vmImage: windows-latest + steps: + - checkout: self + - pwsh: Install-Module -Name psake -Force + displayName: Install psake + - pwsh: Invoke-psake -BuildFile .\psakefile.ps1 -TaskList Build + displayName: Build + env: + BUILD_ENV: dev + + - stage: DeployProduction + dependsOn: Build + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: Deploy + environment: production + pool: + vmImage: windows-latest + strategy: + runOnce: + deploy: + steps: + - checkout: self + - task: AzureCLI@2 + inputs: + azureSubscription: MyProductionServiceConnection + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + Install-Module -Name psake -Force + Invoke-psake -BuildFile .\psakefile.ps1 -TaskList Deploy + env: + BUILD_ENV: prod + DEPLOY_APPROVED: 'true' +``` + +## Best Practices + +1. **Use environment variables** - Set `BUILD_ENV` via environment variables, not hardcoded +2. **Validate early** - Check environment names at the start of the build +3. **Externalize configuration** - Use separate config files for complex environments +4. **Default to development** - Make the safest environment (dev) the default +5. **Require approval for production** - Use protected CI environments and a noninteractive approval signal +6. **Use preconditions** - Leverage psake preconditions for environment-specific tasks +7. **Keep secrets separate** - Never put secrets in environment config files (see [Secret Management](/docs/best-practices/secret-management)) +8. **Test all environments** - Validate builds for all environments in CI/CD +9. **Document environment settings** - Maintain clear documentation of environment differences +10. **Use consistent naming** - Stick to standard names: dev, staging, prod + +## Troubleshooting + +### Environment Not Loading + +**Problem:** Environment configuration not applied + +**Solution:** Check environment variable and file paths: + +```powershell +Task Debug:ShowEnvironment { + $environmentConfig = Join-Path $PSScriptRoot "build/config/$Environment.ps1" + Write-Host "BUILD_ENV: $($env:BUILD_ENV)" -ForegroundColor Yellow + Write-Host "Environment: $Environment" -ForegroundColor Yellow + Write-Host "Config File: $environmentConfig" -ForegroundColor Yellow + Write-Host "File Exists: $(Test-Path $environmentConfig)" -ForegroundColor Yellow +} +``` + +### Wrong Configuration Applied + +**Problem:** Production settings used in development + +**Solution:** Add validation and defaults: + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + + # Validate environment + if ($Environment -notin @('dev', 'staging', 'prod')) { + throw "Invalid environment: $Environment" + } + + # Safety check - prevent accidental production deployments + if ($Environment -eq 'prod' -and -not $env:ALLOW_PROD_DEPLOY) { + throw "Production deployment requires ALLOW_PROD_DEPLOY=true" + } +} +``` + +## See Also + +- [Secret Management](/docs/best-practices/secret-management) - Handling secrets and credentials +- [Organizing Large Scripts](/docs/best-practices/organizing-large-scripts) - Modular build organization +- [Parameters and Properties](/docs/tutorial-basics/parameters-properties) - Using psake properties +- [GitHub Actions](/docs/ci-examples/github-actions) - CI/CD integration examples +- [Azure Pipelines](/docs/ci-examples/azure-pipelines) - Azure DevOps integration diff --git a/docs/best-practices/organizing-large-scripts.md b/docs/best-practices/organizing-large-scripts.md new file mode 100644 index 0000000..61d78f0 --- /dev/null +++ b/docs/best-practices/organizing-large-scripts.md @@ -0,0 +1,757 @@ +--- +title: "Organizing Large Build Scripts" +description: "Best practices for structuring large psake build scripts using modular tasks, includes, and shared utilities for maintainability" +--- + +# 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`: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot +} + +# Include paths must be resolved while the build file is loaded. +$tasksDirectory = Join-Path $PSScriptRoot 'build/tasks' +Include (Join-Path $tasksDirectory 'build.ps1') +Include (Join-Path $tasksDirectory 'test.ps1') +Include (Join-Path $tasksDirectory '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:** + +```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' +$utilsDirectory = Join-Path $buildRoot 'utils' +$tasksDirectory = Join-Path $buildRoot 'tasks' +$configDirectory = Join-Path $buildRoot 'config' +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + +# Includes are resolved while the build file is loaded. +Include (Join-Path $utilsDirectory 'logging.ps1') +Include (Join-Path $utilsDirectory 'fileops.ps1') +Include (Join-Path $utilsDirectory 'versioning.ps1') +Include (Join-Path $configDirectory "${selectedEnvironment}.ps1") +Include (Join-Path $tasksDirectory 'compile.ps1') +Include (Join-Path $tasksDirectory 'test.ps1') +Include (Join-Path $tasksDirectory 'package.ps1') +Include (Join-Path $tasksDirectory 'deploy.ps1') +Include (Join-Path $tasksDirectory 'cleanup.ps1') + +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $Configuration = 'Release' +} + +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:** + +```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' +$buildType = if ($env:BUILD_TYPE) { $env:BUILD_TYPE } else { 'all' } +$buildTasks = @() + +Include (Join-Path $buildRoot 'shared/common.ps1') + +if ($buildType -in 'library', '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') + $buildTasks += 'Library:Build' +} + +if ($buildType -in 'service', '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') + $buildTasks += 'Service:Build' +} + +if ($buildType -in 'tools', 'all') { + Include (Join-Path $buildRoot 'tasks/tools/build.ps1') + Include (Join-Path $buildRoot 'tasks/tools/package.ps1') + $buildTasks += 'Tools:Build' +} + +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $BuildType = if ($env:BUILD_TYPE) { $env:BUILD_TYPE } else { 'all' } +} + +Task Default -depends Build +Task Build -depends $buildTasks +``` + +## Modular Task Files + +Break down complex builds into focused, reusable task files. + +### Example: Compilation Tasks + +**build/tasks/compile.ps1:** + +```powershell +Properties { + # These can reference properties from main psakefile + $SrcDir = Join-Path $ProjectRoot 'src' + $BuildDir = Join-Path $ProjectRoot 'build/output' +} + +function Invoke-SolutionBuild { + param([string]$BuildConfiguration) + + $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 $BuildConfiguration ` + -o $BuildDir ` + /p:Version=$Version ` + --no-incremental + } +} + +Task Compile -depends Clean { + Invoke-SolutionBuild -BuildConfiguration $Configuration + Write-Host "Compilation complete: $BuildDir" -ForegroundColor Green +} + +Task CompileDebug -depends Clean { + Invoke-SolutionBuild -BuildConfiguration Debug +} + +Task CompileRelease -depends Clean { + Invoke-SolutionBuild -BuildConfiguration Release +} + +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:** + +```powershell +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 -Raw + $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:** + +```powershell +$deployTaskByTarget = @{ + azure = 'Deploy:Azure' + aws = 'Deploy:AWS' + local = 'Deploy:Local' +} +$selectedDeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'local' } +if (-not $deployTaskByTarget.ContainsKey($selectedDeployTarget)) { + throw "Unknown deployment target: $selectedDeployTarget" +} + +Properties { + $DeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'local' } + $DeploymentDir = Join-Path $ProjectRoot 'deployment' + $AwsDeploymentBucket = $env:AWS_DEPLOYMENT_BUCKET + $AwsApplicationName = $env:AWS_APPLICATION_NAME + $AwsDeploymentGroup = $env:AWS_DEPLOYMENT_GROUP +} + +Task Deploy -depends $deployTaskByTarget[$selectedDeployTarget] + +Task Deploy:Azure { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile.FullName ` + --type zip + } +} + +Task Deploy:AWS { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + + exec { aws s3 cp $packageFile.FullName "s3://$AwsDeploymentBucket/$($packageFile.Name)" } + exec { + aws deploy create-deployment ` + --application-name $AwsApplicationName ` + --deployment-group-name $AwsDeploymentGroup ` + --s3-location "bucket=$AwsDeploymentBucket,key=$($packageFile.Name),bundleType=zip" + } +} + +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 + +```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' + +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-TaskFile 'tasks/build.ps1' +Include-TaskFile 'tasks/test.ps1' +Include-TaskFile 'tasks/deploy.ps1' +``` + +### Dynamic Includes Based on Configuration + +```powershell +$projectType = if ($env:PROJECT_TYPE) { $env:PROJECT_TYPE } else { 'dotnet' } +$tasksDirectory = Join-Path $PSScriptRoot 'build/tasks' + +Include (Join-Path $tasksDirectory 'common.ps1') + +$projectTaskFile = Join-Path $tasksDirectory "${projectType}.ps1" +if (-not (Test-Path $projectTaskFile)) { + throw "No task file found for project type: $projectType" +} +Include $projectTaskFile + +$optionalTasks = @('docker.ps1', 'kubernetes.ps1', 'terraform.ps1') +foreach ($taskFile in $optionalTasks) { + $fullPath = Join-Path $tasksDirectory $taskFile + if (Test-Path $fullPath) { + Write-Host "Loading optional tasks: $taskFile" -ForegroundColor Gray + Include $fullPath + } +} +``` + +### Include Order Matters + +```powershell +# 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:** + +```powershell +# 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 +} + +``` + +### Example: Logging Utility + +**build/utils/logging.ps1:** + +```powershell +# 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 +} + +``` + +### Example: Versioning Utility + +**build/utils/versioning.ps1:** + +```powershell +# Version management utilities + + +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 -notmatch '^\d+\.\d+\.\d+$') { + throw "Base version is not valid SemVer: $BaseVersion" + } + + return "$BaseVersion-ci.$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 -Raw + + $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 +} + +``` + +## Complete Example: Large Project + +Here's a complete example combining all patterns: + +**psakefile.ps1:** + +```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + +Include (Join-Path $buildRoot 'utils/logging.ps1') +Include (Join-Path $buildRoot 'utils/fileops.ps1') +Include (Join-Path $buildRoot 'utils/versioning.ps1') + +$environmentConfig = Join-Path $buildRoot "config/${selectedEnvironment}.ps1" +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig" +} +Include $environmentConfig + +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') + +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $SrcDir = Join-Path $ProjectRoot 'src' + $TestDir = Join-Path $ProjectRoot 'tests' + $BuildDir = Join-Path $ProjectRoot 'build/output' + $Configuration = if ($env:BUILD_CONFIGURATION) { $env:BUILD_CONFIGURATION } else { 'Debug' } + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + $Version = Get-BuildVersion -BaseVersion '1.0.0' -BuildNumber $BuildNumber +} + +# 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" +} +``` + +The environment and utility files are the complete implementations shown earlier in this guide. Add these remaining task files: + +**build/tasks/package.ps1:** + +```powershell +Task Package -depends Build { + if (-not (Test-Path $BuildDir)) { + throw "Build output not found: $BuildDir" + } + + $packagePath = Join-Path $BuildRoot "MyApp-$Version.zip" + Compress-Archive -Path "$BuildDir/*" -DestinationPath $packagePath -Force + Write-LogSuccess "Created $packagePath" +} +``` + +**build/tasks/cleanup.ps1:** + +```powershell +Task Clean { + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + New-Item -ItemType Directory -Path $BuildDir -Force | Out-Null +} +``` + +Use the `compile.ps1`, `test.ps1`, and `deploy.ps1` implementations from the modular task-file examples above. Together, these files satisfy every `Include` in the orchestrator. + +## Best Practices Summary + +1. **Use a clear directory structure** - Organize by category or build type +2. **Keep task files focused** - One responsibility per file +3. **Load utilities before tasks** - Ensure dependencies are available +4. **Use Include for modularization** - Split large builds into manageable pieces +5. **Create shared utilities** - Avoid duplicating code across task files +6. **Validate file paths** - Check that included files exist +7. **Use meaningful names** - Make task files and functions self-documenting +8. **Document complex logic** - Add comments explaining non-obvious decisions +9. **Keep the main psakefile simple** - It should orchestrate, not implement +10. **Test modular components** - Ensure each task file works independently + +## See Also + +- [Access Functions in Another File](/docs/tutorial-advanced/access-functions-in-another-file) - Using Include and dot-sourcing +- [Structure of a psake Build Script](/docs/tutorial-advanced/structure-of-a-psake-build-script) - Basic script structure +- [Environment Management](/docs/best-practices/environment-management) - Managing multiple environments +- [Testing Build Scripts](/docs/best-practices/testing-build-scripts) - Testing your psake scripts +- [.NET Solution Builds](/docs/build-types/dot-net-solution) - Complete .NET examples diff --git a/docs/best-practices/secret-management.md b/docs/best-practices/secret-management.md new file mode 100644 index 0000000..80692b2 --- /dev/null +++ b/docs/best-practices/secret-management.md @@ -0,0 +1,739 @@ +--- +title: "Secret Management" +description: "Securely handle secrets and credentials in psake builds using environment variables, Azure Key Vault, AWS Secrets Manager, and secure coding practices" +--- + +# Secret Management + +Proper secret management is critical for secure build automation. This guide shows you how to handle API keys, passwords, certificates, and other sensitive data in psake builds without exposing them in source control or logs. + +## Quick Start + +Here's a basic secure approach using environment variables: + +```powershell +Properties { + # Never hardcode secrets! + # BAD: $ApiKey = "sk-1234567890abcdef" + + # GOOD: Read from environment variables + $ApiKey = $env:API_KEY + $DatabasePassword = $env:DB_PASSWORD +} + +Task Deploy { + # Validate secrets exist + if ([string]::IsNullOrEmpty($ApiKey)) { + throw "API_KEY environment variable is required" + } + + if ([string]::IsNullOrEmpty($DatabasePassword)) { + throw "DB_PASSWORD environment variable is required" + } + + # Expose the secret only through the child process environment. + exec { + $env:MYAPP_API_KEY = $ApiKey + try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY } + } -errorMessage "Publish failed (credentials redacted)" +} +``` + +## Secret Management Patterns + +### Pattern 1: Environment Variables + +The simplest and most common approach: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + + # API credentials + $NuGetApiKey = $env:NUGET_API_KEY + $DockerHubToken = $env:DOCKER_TOKEN + $GitHubToken = $env:GITHUB_TOKEN + + # Database credentials + $DbUsername = $env:DB_USERNAME + $DbPassword = $env:DB_PASSWORD + + # Cloud provider credentials + $AzureClientId = $env:AZURE_CLIENT_ID + $AzureClientSecret = $env:AZURE_CLIENT_SECRET + $AwsAccessKeyId = $env:AWS_ACCESS_KEY_ID + $AwsSecretAccessKey = $env:AWS_SECRET_ACCESS_KEY + + # Certificate passwords + $SigningCertPassword = $env:SIGNING_CERT_PASSWORD +} + +Task ValidateSecrets { + Write-Host "Validating required secrets..." -ForegroundColor Green + + $requiredSecrets = @{ + 'NUGET_API_KEY' = $NuGetApiKey + 'DB_PASSWORD' = $DbPassword + 'AZURE_CLIENT_SECRET' = $AzureClientSecret + } + + $missing = @() + foreach ($secret in $requiredSecrets.GetEnumerator()) { + if ([string]::IsNullOrEmpty($secret.Value)) { + $missing += $secret.Key + } + } + + if ($missing.Count -gt 0) { + throw "Missing required secrets: $($missing -join ', ')" + } + + Write-Host " All required secrets are present" -ForegroundColor Green +} + +Task PublishPackage -depends Build, ValidateSecrets { + Write-Host "Publishing NuGet package..." -ForegroundColor Green + + # Use the secret without logging it + $packages = Get-ChildItem "$BuildDir/*.nupkg" + + foreach ($package in $packages) { + exec { + dotnet nuget push $package.FullName ` + --api-key $NuGetApiKey ` + --source https://api.nuget.org/v3/index.json + } -errorMessage "Failed to publish package (check API key)" + } +} +``` + +### Pattern 2: Secure Strings (PowerShell) + +For Windows-specific scenarios using PowerShell secure strings: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $CredentialsFile = Join-Path $ProjectRoot '.credentials/encrypted.xml' +} + +Task SaveCredentials { + Write-Host "Saving encrypted credentials..." -ForegroundColor Green + + # This only works on Windows with DPAPI + $credential = Get-Credential -Message "Enter deployment credentials" + + $credentialsDir = Split-Path $CredentialsFile -Parent + if (-not (Test-Path $credentialsDir)) { + New-Item -ItemType Directory -Path $credentialsDir | Out-Null + } + + # Export encrypted (only readable by current user on current machine) + $credential | Export-Clixml -Path $CredentialsFile + + Write-Host "Credentials saved to: $CredentialsFile" -ForegroundColor Green + Write-Warning "Add .credentials/ to .gitignore!" +} + +Task LoadCredentials { + if (-not (Test-Path $CredentialsFile)) { + throw "Credentials file not found: $CredentialsFile. Run SaveCredentials task first." + } + + # Import encrypted credentials + $script:DeploymentCredential = Import-Clixml -Path $CredentialsFile + + Write-Host "Loaded credentials from: $CredentialsFile" -ForegroundColor Green +} + +Task Deploy -depends Build, LoadCredentials { + Write-Host "Deploying with saved credentials..." -ForegroundColor Green + + $arguments = @( + '-verb:sync' + "-source:package=$BuildDir\package.zip" + '-dest:auto,computerName=https://server.example.com:8172/msdeploy.axd' + '-allowUntrusted' + ) + $process = Start-Process msdeploy ` + -ArgumentList $arguments ` + -Credential $DeploymentCredential ` + -Wait -PassThru -NoNewWindow + + if ($process.ExitCode -ne 0) { + throw "Deployment failed with exit code $($process.ExitCode)" + } +} +``` + +### Pattern 3: Azure Key Vault + +For Azure-hosted secrets: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $KeyVaultName = $env:AZURE_KEYVAULT_NAME +} + +Task AzureLogin { + Write-Host "Authenticating with Azure..." -ForegroundColor Green + + if ($env:CI) { + # CI authenticates before psake with azure/login or an equivalent workload identity. + exec { az account show --output none } + } else { + exec { az login } + } + + Write-Host "Azure authentication successful" -ForegroundColor Green +} + +Task GetSecretsFromKeyVault -depends AzureLogin { + Write-Host "Retrieving secrets from Azure Key Vault: $KeyVaultName" -ForegroundColor Green + + if ([string]::IsNullOrEmpty($KeyVaultName)) { + throw "AZURE_KEYVAULT_NAME environment variable is required" + } + + # Retrieve secrets from Key Vault + $script:DatabasePassword = az keyvault secret show ` + --name "DatabasePassword" ` + --vault-name $KeyVaultName ` + --query value -o tsv + + $script:ApiKey = az keyvault secret show ` + --name "ApiKey" ` + --vault-name $KeyVaultName ` + --query value -o tsv + + $script:CertificatePassword = az keyvault secret show ` + --name "SigningCertPassword" ` + --vault-name $KeyVaultName ` + --query value -o tsv + + # Validate retrieved secrets + if ([string]::IsNullOrEmpty($DatabasePassword)) { + throw "Failed to retrieve DatabasePassword from Key Vault" + } + + Write-Host " Retrieved secrets successfully" -ForegroundColor Green +} + +Task Deploy -depends Build, AzureLogin { + $databaseSecretUri = az keyvault secret show ` + --name DatabasePassword --vault-name $KeyVaultName --query id -o tsv + $apiKeySecretUri = az keyvault secret show ` + --name ApiKey --vault-name $KeyVaultName --query id -o tsv + $packageFile = Join-Path $BuildDir 'package.zip' + + exec { + az webapp config appsettings set ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --settings ` + "ConnectionStrings__DefaultConnection=@Microsoft.KeyVault(SecretUri=$databaseSecretUri)" ` + "ExternalServices__ApiKey=@Microsoft.KeyVault(SecretUri=$apiKeySecretUri)" + } + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile ` + --type zip + } +} +``` + +### Pattern 4: AWS Secrets Manager + +For AWS-hosted secrets: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $AwsRegion = if ($env:AWS_REGION) { $env:AWS_REGION } else { 'us-east-1' } + $SecretsPath = 'myapp/prod' # Path in Secrets Manager +} + +Task VerifyAwsCli { + try { + $awsVersion = aws --version + Write-Host "AWS CLI: $awsVersion" -ForegroundColor Gray + } + catch { + throw "AWS CLI is not installed. Install from https://aws.amazon.com/cli/" + } +} + +Task GetSecretsFromAWS -depends VerifyAwsCli { + Write-Host "Retrieving secrets from AWS Secrets Manager..." -ForegroundColor Green + + # Get secret from AWS Secrets Manager + $secretJson = aws secretsmanager get-secret-value ` + --secret-id $SecretsPath ` + --region $AwsRegion ` + --query SecretString ` + --output text + + if ([string]::IsNullOrEmpty($secretJson)) { + throw "Failed to retrieve secrets from AWS Secrets Manager: $SecretsPath" + } + + # Parse secrets JSON + $secrets = $secretJson | ConvertFrom-Json + + # Extract individual secrets + $script:DatabasePassword = $secrets.DatabasePassword + $script:ApiKey = $secrets.ApiKey + $script:EncryptionKey = $secrets.EncryptionKey + + # Validate + if ([string]::IsNullOrEmpty($DatabasePassword)) { + throw "DatabasePassword not found in secrets" + } + + Write-Host " Retrieved secrets successfully" -ForegroundColor Green +} + +Task SetAwsSecret { + param( + [string]$SecretName = 'myapp/prod', + [SecureString]$SecretValue + ) + + if ($null -eq $SecretValue) { + $SecretValue = Read-Host "Enter secret value" -AsSecureString + } + + Import-Module AWS.Tools.SecretsManager + $credential = [PSCredential]::new('secret', $SecretValue) + $plainText = $credential.GetNetworkCredential().Password + + try { + $existingSecret = Get-SECSecretList | + Where-Object Name -eq $SecretName | + Select-Object -First 1 + + if ($existingSecret) { + Update-SECSecret -SecretId $SecretName -SecretString $plainText + Write-Host "Secret updated: $SecretName" -ForegroundColor Green + } else { + New-SECSecret -Name $SecretName -SecretString $plainText + Write-Host "Secret created: $SecretName" -ForegroundColor Green + } + } + finally { + $plainText = $null + $credential = $null + $SecretValue.Dispose() + } +} + +Task Deploy -depends Build, GetSecretsFromAWS { + Write-Host "Deploying with AWS Secrets..." -ForegroundColor Green + + # Use secrets in deployment + exec { + aws deploy create-deployment ` + --application-name MyApp ` + --deployment-config-name CodeDeployDefault.OneAtATime ` + --deployment-group-name Production ` + --s3-location bucket=my-deployments,key=app.zip,bundleType=zip + } +} +``` + +### Pattern 5: HashiCorp Vault + +For HashiCorp Vault integration: + +```powershell +Properties { + $VaultAddr = $env:VAULT_ADDR # e.g., https://vault.example.com:8200 + $VaultToken = $env:VAULT_TOKEN + $VaultSecretsPath = 'secret/data/myapp/prod' +} + +Task GetSecretsFromVault { + Write-Host "Retrieving secrets from HashiCorp Vault..." -ForegroundColor Green + + if ([string]::IsNullOrEmpty($VaultAddr)) { + throw "VAULT_ADDR environment variable is required" + } + + if ([string]::IsNullOrEmpty($VaultToken)) { + throw "VAULT_TOKEN environment variable is required" + } + + # Retrieve secret from Vault + $headers = @{ + 'X-Vault-Token' = $VaultToken + } + + try { + $response = Invoke-RestMethod ` + -Uri "$VaultAddr/v1/$VaultSecretsPath" ` + -Method Get ` + -Headers $headers + + $secretData = $response.data.data + + # Extract secrets + $script:DatabasePassword = $secretData.database_password + $script:ApiKey = $secretData.api_key + $script:EncryptionKey = $secretData.encryption_key + + Write-Host " Retrieved secrets successfully" -ForegroundColor Green + } + catch { + throw "Failed to retrieve secrets from Vault: $_" + } +} +``` + +## Security Best Practices + +### Never Commit Secrets + +**Always add these to .gitignore:** + +```gitignore +# Secrets and credentials +*.key +*.pem +*.pfx +*.p12 +*.cer +*.crt +.env +.env.local +.env.*.local +secrets.json +appsettings.*.json +*.credentials +.credentials/ +*.secret + +# Configuration with secrets +config/prod.ps1 +config/staging.ps1 +**/appsettings.Production.json +**/appsettings.Staging.json + +# Build artifacts that may contain secrets +publish/ +deploy/ +``` + +### Avoid Logging Secrets + +**Bad - Secret appears in logs:** + +```powershell +Task Deploy { + Write-Host "Using API key: $ApiKey" -ForegroundColor Gray # NEVER DO THIS! + exec { dotnet publish } +} +``` + +**Good - Secrets redacted:** + +```powershell +Task Deploy { + Write-Host "Using API key: [REDACTED]" -ForegroundColor Gray + + exec { + $env:MYAPP_API_KEY = $ApiKey + try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY } + } -errorMessage "Publish failed (check API key configuration)" +} +``` + +### Secret Validation + +Always validate secrets exist before using them: + +```powershell +function Test-SecretExists { + param( + [string]$SecretName, + [string]$SecretValue + ) + + if ([string]::IsNullOrEmpty($SecretValue)) { + throw "Required secret '$SecretName' is not set" + } + + # Optionally validate format + if ($SecretName -like '*API_KEY' -and $SecretValue.Length -lt 20) { + Write-Warning "Secret '$SecretName' appears to be invalid (too short)" + } +} + +Task ValidateSecrets { + Write-Host "Validating secrets..." -ForegroundColor Green + + Test-SecretExists -SecretName 'NUGET_API_KEY' -SecretValue $env:NUGET_API_KEY + Test-SecretExists -SecretName 'DB_PASSWORD' -SecretValue $env:DB_PASSWORD + Test-SecretExists -SecretName 'SIGNING_CERT_PASSWORD' -SecretValue $env:SIGNING_CERT_PASSWORD + + Write-Host " All secrets validated" -ForegroundColor Green +} +``` + +### Secure Certificate Handling + +**For code signing certificates:** + +```powershell +Properties { + $CertificateThumbprint = $env:SIGNING_CERT_THUMBPRINT +} + +Task SignAssemblies -depends Build { + Write-Host "Signing assemblies..." -ForegroundColor Green + + if ([string]::IsNullOrEmpty($CertificateThumbprint)) { + throw "SIGNING_CERT_THUMBPRINT environment variable is required" + } + + $assemblies = Get-ChildItem "$BuildDir/*.dll" -Recurse + foreach ($assembly in $assemblies) { + exec { + signtool sign /sha1 $CertificateThumbprint ` + /tr http://timestamp.digicert.com ` + /td SHA256 ` + /fd SHA256 ` + $assembly.FullName + } -errorMessage "Failed to sign $($assembly.Name)" + + Write-Host " Signed: $($assembly.Name)" -ForegroundColor Gray + } +} +``` + +### Minimize Secret Lifetime + +Strings cannot be reliably erased from managed memory. Prefer APIs that accept `SecureString`, scope secrets narrowly, and dispose sensitive objects deterministically: + +```powershell +Task InstallSigningCertificate { + $password = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $certificate = $null + + try { + $certificate = Import-PfxCertificate ` + -FilePath $CertificatePath ` + -CertStoreLocation Cert:\CurrentUser\My ` + -Password $password + + exec { signtool sign /sha1 $certificate.Thumbprint /fd SHA256 $PackagePath } + } + finally { + if ($certificate) { + Remove-Item $certificate.PSPath -Force + } + $password.Dispose() + } +} +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Deploy with Secrets + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install psake + shell: pwsh + run: Install-Module -Name psake -Force + + - name: Deploy + shell: pwsh + run: | + Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy + env: + # Pass secrets as environment variables + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }} +``` + +### Azure Pipelines + +```yaml +trigger: + - main + +pool: + vmImage: 'windows-latest' + +variables: + - group: production-secrets # Variable group with secrets + +steps: + - task: PowerShell@2 + displayName: 'Install psake' + inputs: + targetType: 'inline' + script: 'Install-Module -Name psake -Force' + + - task: PowerShell@2 + displayName: 'Deploy' + inputs: + targetType: 'inline' + script: 'Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy' + env: + NUGET_API_KEY: $(NuGetApiKey) + DB_PASSWORD: $(DatabasePassword) + AZURE_CLIENT_SECRET: $(AzureClientSecret) +``` + +## Complete Secure Build Example + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $BuildDir = Join-Path $ProjectRoot 'build/output' + + # Secrets from environment variables + $NuGetApiKey = $env:NUGET_API_KEY + $AzureKeyVaultName = $env:AZURE_KEYVAULT_NAME + + # Flags + $UseKeyVault = -not [string]::IsNullOrEmpty($AzureKeyVaultName) +} + +Task ValidateSecrets { + Write-Host "Validating secrets configuration..." -ForegroundColor Green + + if ($UseKeyVault) { + Write-Host " Using Azure Key Vault: $AzureKeyVaultName" -ForegroundColor Gray + + if ([string]::IsNullOrEmpty($env:AZURE_CLIENT_SECRET)) { + throw "AZURE_CLIENT_SECRET is required for Key Vault access" + } + } else { + Write-Host " Using environment variables" -ForegroundColor Gray + + if ([string]::IsNullOrEmpty($NuGetApiKey)) { + throw "NUGET_API_KEY environment variable is required" + } + } + + Write-Host " Secrets validation passed" -ForegroundColor Green +} + +Task GetSecrets -depends ValidateSecrets, GetSecretsFromKeyVault { + if (-not $UseKeyVault) { + Write-Host "Using secrets from environment variables" -ForegroundColor Gray + } +} + +Task GetSecretsFromKeyVault -precondition { $UseKeyVault } { + Write-Host "Retrieving secrets from Azure Key Vault..." -ForegroundColor Green + + # CI authenticates before psake with azure/login or an equivalent workload identity. + $script:NuGetApiKey = az keyvault secret show ` + --name "NuGetApiKey" ` + --vault-name $AzureKeyVaultName ` + --query value -o tsv + + Write-Host " Secrets retrieved successfully" -ForegroundColor Green +} + +Task Build { + Write-Host "Building project..." -ForegroundColor Green + exec { dotnet build -c Release -o $BuildDir } +} + +Task Pack -depends Build { + Write-Host "Creating NuGet packages..." -ForegroundColor Green + exec { dotnet pack -c Release -o $BuildDir --no-build } +} + +Task Publish -depends Pack, GetSecrets { + Write-Host "Publishing packages to NuGet..." -ForegroundColor Green + + try { + $packages = Get-ChildItem "$BuildDir/*.nupkg" + foreach ($package in $packages) { + Write-Host " Publishing: $($package.Name)" -ForegroundColor Gray + exec { + dotnet nuget push $package.FullName ` + --api-key $NuGetApiKey ` + --source https://api.nuget.org/v3/index.json + } -errorMessage "Failed to publish package (credentials redacted)" + } + Write-Host "Publishing complete" -ForegroundColor Green + } + finally { + $script:NuGetApiKey = $null + } +} +``` + +## Troubleshooting + +### Secret Not Found + +**Problem:** Environment variable not set + +**Solution:** + +```powershell +Task Debug:ShowSecrets { + Write-Host "Secret Status:" -ForegroundColor Yellow + Write-Host " NUGET_API_KEY: $(if ($env:NUGET_API_KEY) { '[SET]' } else { '[NOT SET]' })" + Write-Host " DB_PASSWORD: $(if ($env:DB_PASSWORD) { '[SET]' } else { '[NOT SET]' })" + Write-Host " AZURE_CLIENT_SECRET: $(if ($env:AZURE_CLIENT_SECRET) { '[SET]' } else { '[NOT SET]' })" +} +``` + +### Secrets Appearing in Logs + +**Problem:** Sensitive data in build output + +**Solution:** Use `-errorMessage` with exec and avoid Write-Host with secret values + +### Key Vault Access Denied + +**Problem:** Cannot access Azure Key Vault + +**Solution:** Check service principal permissions: + +```powershell +Task GrantKeyVaultAccess { + $servicePrincipalId = $env:AZURE_CLIENT_ID + + exec { + az keyvault set-policy ` + --name $env:AZURE_KEYVAULT_NAME ` + --spn $servicePrincipalId ` + --secret-permissions get list + } +} +``` + +## See Also + +- [Environment Management](/docs/best-practices/environment-management) - Managing multiple environments +- [GitHub Actions](/docs/ci-examples/github-actions) - CI/CD with GitHub +- [Azure Pipelines](/docs/ci-examples/azure-pipelines) - CI/CD with Azure DevOps +- [Docker Builds](/docs/build-types/docker) - Container builds with secrets diff --git a/docs/best-practices/testing-build-scripts.md b/docs/best-practices/testing-build-scripts.md new file mode 100644 index 0000000..b1a8daf --- /dev/null +++ b/docs/best-practices/testing-build-scripts.md @@ -0,0 +1,721 @@ +--- +title: "Testing Build Scripts" +description: "Test your psake build scripts using Pester, mock external commands, validate task dependencies, and integrate with CI/CD pipelines" +--- + +# Testing Build Scripts + +Build scripts are code and should be tested like any other code. This guide shows you how to write tests for psake scripts using Pester, mock external dependencies, validate task execution, and integrate testing into your CI/CD pipeline. + +## Quick Start + +Here's a basic Pester test for a psake build script: + +```powershell +# tests/Build.Tests.ps1 + +Describe 'psake Build Script' { + BeforeAll { + # Import psake + Import-Module psake -Force + + # Set up test environment + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + } + + It 'Build file exists' { + Test-Path $BuildFile | Should -Be $true + } + + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw + } + + It 'Default task executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -nologo + $result.Success | Should -BeTrue + } +} +``` + +Run the tests: + +```powershell +Invoke-Pester -Path ./tests/Build.Tests.ps1 +``` + +## Setting Up Pester + +### Installation + +```powershell +# Install Pester (v5+) +Install-Module -Name Pester -Force -SkipPublisherCheck + +# Verify installation +Get-Module -Name Pester -ListAvailable +``` + +### Basic Test Structure + +**tests/Build.Tests.ps1:** + +```powershell +BeforeAll { + # Import required modules + Import-Module psake -Force + + # Define paths + $script:ProjectRoot = Split-Path $PSScriptRoot -Parent + $script:BuildFile = Join-Path $ProjectRoot 'psakefile.ps1' + $script:BuildDir = Join-Path $ProjectRoot 'build/output' + + # Mock external commands if needed + Mock -CommandName 'dotnet' -MockWith { return 0 } +} + +Describe 'psake Build Configuration' { + It 'Build file exists' { + Test-Path $BuildFile | Should -Be $true + } + + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw + } +} + +Describe 'Build Tasks' { + Context 'Clean Task' { + It 'Removes build directory' { + # Create test build directory + New-Item -ItemType Directory -Path $BuildDir -Force + + # Run Clean task + Invoke-psake -buildFile $BuildFile -taskList Clean -nologo + + # Verify directory removed + Test-Path $BuildDir | Should -Be $false + } + } + + Context 'Build Task' { + It 'Executes without errors' { + $result = Invoke-psake -buildFile $BuildFile -taskList Build -nologo + $result.Success | Should -BeTrue + } + + It 'Creates build artifacts' { + Invoke-psake -buildFile $BuildFile -taskList Build -nologo + (Get-ChildItem $BuildDir).Count | Should -BeGreaterThan 0 + } + } +} + +AfterAll { + # Clean up test artifacts + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } +} +``` + +## Testing Task Dependencies + +Ensure tasks execute in the correct order: + +```powershell +# tests/TaskDependencies.Tests.ps1 + +Describe 'Task Dependencies' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + $script:Tasks = Get-PSakeScriptTasks -BuildFile $BuildFile + } + + It 'Build depends on Compile' { + $buildTask = $Tasks | Where-Object Name -eq 'Build' + $buildTask.DependsOn | Should -Contain 'Compile' + } + + It 'Deploy depends on Build and Test' { + $deployTask = $Tasks | Where-Object Name -eq 'Deploy' + $deployTask.DependsOn | Should -Contain 'Build' + $deployTask.DependsOn | Should -Contain 'Test' + } + + It 'Executes dependencies before Deploy' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList Deploy -NoLogo + $result.Success | Should -BeTrue + + $executedTasks = @($result.Tasks | Where-Object Status -eq 'Executed' | ForEach-Object Name) + $executedTasks.IndexOf('Compile') | Should -BeLessThan $executedTasks.IndexOf('Build') + $executedTasks.IndexOf('Build') | Should -BeLessThan $executedTasks.IndexOf('Deploy') + } +} +``` + +## Mocking External Commands + +Mock external tools to test build logic without side effects: + +### Mocking dotnet CLI + +```powershell +Describe 'Build with Mocked dotnet' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + + # Mock dotnet commands + Mock -CommandName 'dotnet' -MockWith { + switch ($args[0]) { + 'build' { 'Build succeeded' } + 'test' { 'Tests passed: 50 passed, 0 failed' } + 'publish' { 'Publish succeeded' } + } + } + } + + It 'Compile task calls dotnet build' { + Invoke-psake -buildFile $BuildFile -taskList Compile -nologo + + # Verify dotnet build was called + Should -Invoke -CommandName 'dotnet' -ParameterFilter { + $args[0] -eq 'build' + } -Times 1 + } + + It 'Test task calls dotnet test' { + Invoke-psake -buildFile $BuildFile -taskList Test -nologo + + Should -Invoke -CommandName 'dotnet' -ParameterFilter { + $args[0] -eq 'test' + } -Times 1 + } +} +``` + +### Mocking File System Operations + +```powershell +Describe 'File Operations' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + + # Mock file system commands + Mock -CommandName 'Remove-Item' -MockWith { return $true } + Mock -CommandName 'New-Item' -MockWith { + param($Path, $ItemType) + return [PSCustomObject]@{ + FullName = $Path + Exists = $true + } + } + Mock -CommandName 'Copy-Item' -MockWith { return $true } + } + + It 'Clean task removes build directory' { + Invoke-psake -buildFile $BuildFile -taskList Clean -nologo + + Should -Invoke -CommandName 'Remove-Item' -Times 1 + } + + It 'Package task creates deployment package' { + Invoke-psake -buildFile $BuildFile -taskList Package -nologo + + Should -Invoke -CommandName 'Compress-Archive' -Times 1 + } +} +``` + +### Mocking Cloud CLI Tools + +```powershell +Describe 'Azure Deployment' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + + # Mock az CLI + Mock -CommandName 'az' -MockWith { + if ($args[0] -eq 'login') { + return '[{"cloudName":"AzureCloud","state":"Enabled"}]' + } + + if ($args[0] -eq 'webapp') { + return '{"status":"Success"}' + } + } + } + + It 'Deploy task authenticates with Azure' { + Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + + Should -Invoke -CommandName 'az' -ParameterFilter { + $args[0] -eq 'login' + } -Times 1 + } + + It 'Deploy task deploys to Azure Web App' { + Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + + Should -Invoke -CommandName 'az' -ParameterFilter { + $args[0] -eq 'webapp' + } -Times 1 + } +} +``` + +### Mocking AWS CLI + +```powershell +Describe 'AWS Deployment' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + Mock -CommandName 'aws' -MockWith { + if ($args[0] -eq 'deploy' -and $args[1] -eq 'create-deployment') { + return '{"deploymentId":"d-1234567890"}' + } + } + } + + It 'Creates a CodeDeploy deployment' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList Deploy:AWS -NoLogo + $result.Success | Should -BeTrue + Should -Invoke -CommandName 'aws' -ParameterFilter { + $args[0] -eq 'deploy' -and $args[1] -eq 'create-deployment' + } -Times 1 + } +} +``` + +## Testing Properties and Parameters + +Put assertions that depend on build properties inside tasks, where psake properties are in scope: + +```powershell +# psakefile.ps1 +Properties { + $Configuration = 'Debug' + $Environment = 'dev' +} + +Task ValidateDebugConfiguration { + Assert ($Configuration -eq 'Debug') 'Expected Debug configuration' +} + +Task ValidateReleaseConfiguration { + Assert ($Configuration -eq 'Release') 'Expected Release configuration' +} + +Task ValidateDevelopmentEnvironment { + Assert ($Environment -eq 'dev') 'Expected dev environment' +} + +Task ValidateStagingEnvironment { + Assert ($Environment -eq 'staging') 'Expected staging environment' +} +``` + +```powershell +Describe 'Build Properties' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + } + + It 'Uses the default Debug configuration' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList ValidateDebugConfiguration -NoLogo + $result.Success | Should -BeTrue + } + + It 'Accepts a Release configuration override' { + $result = Invoke-psake -BuildFile $BuildFile ` + -Properties @{ Configuration = 'Release' } ` + -TaskList ValidateReleaseConfiguration -NoLogo + $result.Success | Should -BeTrue + } + + It 'Uses the default dev environment' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList ValidateDevelopmentEnvironment -NoLogo + $result.Success | Should -BeTrue + } +} +``` + +## Integration Tests + +Test the complete build pipeline: + +```powershell +# tests/Integration.Tests.ps1 + +Describe 'Complete Build Pipeline' { + BeforeAll { + $script:ProjectRoot = Split-Path $PSScriptRoot -Parent + $script:BuildFile = Join-Path $ProjectRoot 'psakefile.ps1' + $script:BuildDir = Join-Path $ProjectRoot 'build/output' + $script:TestResultsDir = Join-Path $ProjectRoot 'TestResults' + } + + Context 'Full Build' { + It 'Completes without errors' { + $result = Invoke-psake -buildFile $BuildFile -nologo + $result.Success | Should -BeTrue + } + + It 'Creates build artifacts' { + Test-Path $BuildDir | Should -Be $true + (Get-ChildItem $BuildDir -Recurse -File).Count | Should -BeGreaterThan 0 + } + + It 'Runs tests and generates results' { + Test-Path $TestResultsDir | Should -Be $true + } + + It 'Build artifacts are valid' { + $dlls = Get-ChildItem "$BuildDir/*.dll" -Recurse + + foreach ($dll in $dlls) { + # Verify DLL can be loaded + { [System.Reflection.Assembly]::LoadFrom($dll.FullName) } | Should -Not -Throw + } + } + } + + Context 'Different Configurations' { + It 'Debug build succeeds' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Debug' } -nologo + $result.Success | Should -BeTrue + } + + It 'Release build succeeds' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Release' } -nologo + $result.Success | Should -BeTrue + } + } + + AfterAll { + # Clean up + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + if (Test-Path $TestResultsDir) { + Remove-Item $TestResultsDir -Recurse -Force + } + } +} +``` + +## Testing Error Handling + +Ensure build fails gracefully: + +```powershell +Describe 'Error Handling' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + } + + It 'Build fails when compilation fails' { + Mock -CommandName 'dotnet' -MockWith { throw "Compilation failed" } + + $result = Invoke-psake -buildFile $BuildFile -taskList Compile -nologo + $result.Success | Should -BeFalse + } + + It 'Build fails when tests fail' { + Mock -CommandName 'dotnet' -MockWith { + if ($args[0] -eq 'test') { throw "Tests failed" } + } + + $result = Invoke-psake -buildFile $BuildFile -taskList Test -nologo + $result.Success | Should -BeFalse + } + + It 'Build validates required secrets' { + $originalApiKey = $env:API_KEY + $env:API_KEY = $null + + try { + $result = Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + $result.Success | Should -BeFalse + } + finally { + $env:API_KEY = $originalApiKey + } + } +} +``` + +## Test-Friendly Build Scripts + +Make your build scripts easier to test: + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $TestMode = $env:PSAKE_TEST_MODE -eq 'true' +} + +# Helper function for testable external commands +function Invoke-ExternalCommand { + param( + [string]$Command, + [string[]]$Arguments + ) + + if ($TestMode) { + # In test mode, just log what would be executed + Write-Host "TEST MODE: Would execute: $Command $($Arguments -join ' ')" + return $true + } + + # Normal execution + & $Command @Arguments + return $LASTEXITCODE -eq 0 +} + +Task Build { + Write-Host "Building..." -ForegroundColor Green + + $success = Invoke-ExternalCommand -Command 'dotnet' -Arguments @('build', '-c', $Configuration) + + if (-not $success) { + throw "Build failed" + } +} + +Task Test -depends Build { + Write-Host "Running tests..." -ForegroundColor Green + + $success = Invoke-ExternalCommand -Command 'dotnet' -Arguments @('test') + + if (-not $success) { + throw "Tests failed" + } +} + +# Expose task information for testing +Task ShowTasks { + Get-PSakeScriptTasks | ForEach-Object { + Write-Host "Task: $($_.Name)" -ForegroundColor Cyan + Write-Host " Depends: $($_.DependsOn -join ', ')" -ForegroundColor Gray + Write-Host " Precondition: $($null -ne $_.Precondition)" -ForegroundColor Gray + } +} +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Test Build Scripts + +on: [push, pull_request] + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + shell: pwsh + run: | + Install-Module -Name psake -Force + Install-Module -Name Pester -Force -SkipPublisherCheck + + - name: Run build script tests + shell: pwsh + run: | + Invoke-Pester -Path ./tests -Output Detailed -CI + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./testResults.xml +``` + +### Complete Test Configuration + +**PesterConfiguration.ps1:** + +```powershell +# Configure Pester +$config = New-PesterConfiguration + +# General settings +$config.Run.Path = './tests' +$config.Run.PassThru = $true +$config.Run.Exit = $true + +# Output settings +$config.Output.Verbosity = 'Detailed' + +# Test result export +$config.TestResult.Enabled = $true +$config.TestResult.OutputFormat = 'NUnitXml' +$config.TestResult.OutputPath = './testResults.xml' + +# Code coverage +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './psakefile.ps1', './build/**/*.ps1' +$config.CodeCoverage.OutputFormat = 'JaCoCo' +$config.CodeCoverage.OutputPath = './coverage.xml' + +# Run tests +$result = Invoke-Pester -Configuration $config + +# Exit with test result status +exit $result.FailedCount +``` + +## Complete Test Suite Example + +**tests/BuildScript.Tests.ps1:** + +```powershell +BeforeAll { + # Import modules + Import-Module psake -Force + Import-Module Pester -Force + + # Set up paths + $script:ProjectRoot = Split-Path $PSScriptRoot -Parent + $script:BuildFile = Join-Path $ProjectRoot 'psakefile.ps1' + $script:BuildDir = Join-Path $ProjectRoot 'build/output' + + # Enable test mode + $env:PSAKE_TEST_MODE = 'true' +} + +Describe 'Build Script Validation' { + Context 'File Structure' { + It 'Build file exists' { + Test-Path $BuildFile | Should -Be $true + } + + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw + } + + It 'Build tasks directory exists' { + $tasksDir = Join-Path $ProjectRoot 'build/tasks' + Test-Path $tasksDir | Should -BeTrue + } + } + + Context 'Task Definitions' { + BeforeAll { + $script:Tasks = Get-PSakeScriptTasks -BuildFile $BuildFile + } + + It 'Defines Default task' { + $Tasks.Name | Should -Contain 'Default' + } + + It 'Defines Build task' { + $Tasks.Name | Should -Contain 'Build' + } + + It 'Defines Test task' { + $Tasks.Name | Should -Contain 'Test' + } + + It 'Test task depends on Build' { + $testTask = $Tasks | Where-Object Name -eq 'Test' + $testTask.DependsOn | Should -Contain 'Build' + } + } + + Context 'Task Execution' { + BeforeEach { + # Clean before each test + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + } + + It 'Clean task executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -taskList Clean -nologo + $result.Success | Should -BeTrue + } + + It 'Build task executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -taskList Build -nologo + $result.Success | Should -BeTrue + } + + It 'Full pipeline executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -nologo + $result.Success | Should -BeTrue + } + } + + Context 'Properties and Configuration' { + It 'Respects Configuration property' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Release' } -taskList ValidateReleaseConfiguration -nologo + $result.Success | Should -BeTrue + } + + It 'Respects Environment property' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Environment = 'staging' } -taskList ValidateStagingEnvironment -nologo + $result.Success | Should -BeTrue + } + } + + Context 'Error Handling' { + It 'Fails gracefully on invalid task' { + $result = Invoke-psake -buildFile $BuildFile -taskList InvalidTask -nologo + $result.Success | Should -BeFalse + } + + It 'Validates required environment variables' { + $originalEnv = $env:REQUIRED_VAR + $env:REQUIRED_VAR = $null + + try { + $result = Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + $result.Success | Should -BeFalse + } + finally { + $env:REQUIRED_VAR = $originalEnv + } + } + } +} + +AfterAll { + # Clean up + $env:PSAKE_TEST_MODE = $null + + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } +} +``` + +## Best Practices + +1. **Test early and often** - Run tests during development +2. **Mock external dependencies** - Don't rely on external services in tests +3. **Test both success and failure paths** - Ensure proper error handling +4. **Use test mode flags** - Allow build scripts to run in test mode +5. **Test task dependencies** - Verify tasks execute in correct order +6. **Test all configurations** - Validate Debug, Release, and different environments +7. **Keep tests fast** - Mock slow operations +8. **Use meaningful test names** - Describe what's being tested +9. **Clean up after tests** - Remove test artifacts +10. **Integrate with CI/CD** - Run tests automatically on every commit + +## See Also + +- [Organizing Large Scripts](/docs/best-practices/organizing-large-scripts) - Modular build organization +- [Environment Management](/docs/best-practices/environment-management) - Testing multiple environments +- [GitHub Actions](/docs/ci-examples/github-actions) - CI/CD integration +- [Debug Script](/docs/tutorial-advanced/debug-script) - Debugging psake scripts +- [Logging and Errors](/docs/tutorial-advanced/logging-errors) - Error handling diff --git a/docs/best-practices/versioning-strategy.md b/docs/best-practices/versioning-strategy.md new file mode 100644 index 0000000..356757e --- /dev/null +++ b/docs/best-practices/versioning-strategy.md @@ -0,0 +1,684 @@ +--- +title: "Build Versioning Strategies" +description: "Implement effective build versioning using semantic versioning, git-based versioning, CI build numbers, and automated assembly version updates" +--- + +# Build Versioning Strategies + +Proper version management ensures traceability, reproducibility, and clear release history. This guide shows you how to implement versioning strategies in psake using semantic versioning, git tags, CI build numbers, and automated assembly updates. + +## Quick Start + +Here's a basic versioning setup using git tags and build numbers: + +```powershell +function Get-GitVersion { + try { + $tag = git describe --tags --abbrev=0 2>$null + if ($tag -match '^v?(\d+\.\d+\.\d+)$') { + return $matches[1] + } + } + catch { } + + return '1.0.0' +} + +Properties { + $BaseVersion = Get-GitVersion + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + $Version = "$BaseVersion-ci.$BuildNumber" +} + +Task Build { + Write-Host "Building version: $Version" -ForegroundColor Cyan + + exec { + dotnet build -c Release /p:Version=$Version + } +} +``` + +## Semantic Versioning (SemVer) + +Semantic versioning (MAJOR.MINOR.PATCH) is the industry standard: + +- **MAJOR** - Breaking changes (incompatible API changes) +- **MINOR** - New features (backward-compatible functionality) +- **PATCH** - Bug fixes (backward-compatible bug fixes) + +### Manual Semantic Versioning + +```powershell +Properties { + # Manually maintained version + $MajorVersion = 1 + $MinorVersion = 5 + $PatchVersion = 2 + + # CI build number + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + + # Construct versions + $SemanticVersion = "$MajorVersion.$MinorVersion.$PatchVersion" + $AssemblyVersion = "$MajorVersion.$MinorVersion.0.0" + $FileVersion = "$MajorVersion.$MinorVersion.$PatchVersion.$BuildNumber" + $InformationalVersion = "$SemanticVersion+build.$BuildNumber" +} + +Task ShowVersion { + Write-Host "Version Information:" -ForegroundColor Cyan + Write-Host " Semantic Version: $SemanticVersion" -ForegroundColor Gray + Write-Host " Assembly Version: $AssemblyVersion" -ForegroundColor Gray + Write-Host " File Version: $FileVersion" -ForegroundColor Gray + Write-Host " Informational Version: $InformationalVersion" -ForegroundColor Gray +} + +Task Build { + exec { + dotnet build ` + /p:Version=$SemanticVersion ` + /p:AssemblyVersion=$AssemblyVersion ` + /p:FileVersion=$FileVersion ` + /p:InformationalVersion=$InformationalVersion + } +} +``` + +### Pre-release Versions + +```powershell +Properties { + $MajorVersion = 2 + $MinorVersion = 0 + $PatchVersion = 0 + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + + # Determine pre-release label + $Branch = if ($env:BRANCH_NAME) { $env:BRANCH_NAME } else { 'develop' } + + $PreReleaseLabel = switch -Regex ($Branch) { + '^main$|^master$' { '' } # Production release + '^release/.*' { 'rc' } # Release candidate + '^develop$' { 'beta' } # Beta release + '^feature/.*' { 'alpha' } # Alpha release + default { 'dev' } # Development build + } + + # Construct version + if ([string]::IsNullOrEmpty($PreReleaseLabel)) { + $Version = "$MajorVersion.$MinorVersion.$PatchVersion" + } else { + $Version = "$MajorVersion.$MinorVersion.$PatchVersion-$PreReleaseLabel.$BuildNumber" + } +} + +Task Build { + Write-Host "Building version: $Version" -ForegroundColor Cyan + Write-Host " Branch: $Branch" -ForegroundColor Gray + Write-Host " Pre-release: $PreReleaseLabel" -ForegroundColor Gray + + exec { + dotnet build ` + -c Release ` + /p:Version=$Version ` + /p:VersionPrefix="$MajorVersion.$MinorVersion.$PatchVersion" ` + /p:VersionSuffix=$PreReleaseLabel + } +} +``` + +## Git-Based Versioning + +Derive versions from git tags and commit history: + +### Using Git Tags + +The `Get-GitVersion` helper from the quick start validates tags as `X.Y.Z`. Add commit distance when the checkout is ahead of the tag: + +```powershell +Properties { + $LatestTag = git describe --tags --abbrev=0 2>$null + $BaseVersion = Get-GitVersion + $CommitsSinceTag = if ($LatestTag) { git rev-list "$LatestTag..HEAD" --count 2>$null } else { 0 } + $Version = if ($CommitsSinceTag -gt 0) { + "$BaseVersion-dev.$CommitsSinceTag" + } else { + $BaseVersion + } +} + + +Task Build { + Write-Host "Git-based version: $Version" -ForegroundColor Cyan + + exec { + dotnet build -c Release /p:Version=$Version + } +} + +Task CreateTag { + param( + [string]$TagVersion = '1.0.0', + [string]$Message = 'Release' + ) + + Write-Host "Creating git tag: v$TagVersion" -ForegroundColor Green + + # Validate version format + if ($TagVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "Invalid version format: $TagVersion (expected: MAJOR.MINOR.PATCH)" + } + + # Create annotated tag + exec { git tag -a "v$TagVersion" -m $Message } + + Write-Host "Tag created successfully. Push with: git push origin v$TagVersion" -ForegroundColor Yellow +} +``` + +### Using GitVersion Tool + +```powershell +Properties { + $GitVersionExe = 'gitversion' +} + +Task InstallGitVersion { + Write-Host "Installing GitVersion..." -ForegroundColor Green + + exec { dotnet tool install --global GitVersion.Tool } +} + +Task GetGitVersion { + Write-Host "Calculating version with GitVersion..." -ForegroundColor Green + + # Run GitVersion and parse output + $versionJson = & $GitVersionExe | ConvertFrom-Json + + # Extract version components + $script:Version = $versionJson.SemVer + $script:MajorMinorPatch = $versionJson.MajorMinorPatch + $script:InformationalVersion = $versionJson.InformationalVersion + $script:AssemblyVersion = $versionJson.AssemblySemVer + $script:FileVersion = $versionJson.AssemblySemFileVer + $script:NuGetVersion = $versionJson.NuGetVersionV2 + + Write-Host " SemVer: $Version" -ForegroundColor Cyan + Write-Host " NuGet: $NuGetVersion" -ForegroundColor Gray + Write-Host " Assembly: $AssemblyVersion" -ForegroundColor Gray +} + +Task Build -depends GetGitVersion { + exec { + dotnet build ` + /p:Version=$NuGetVersion ` + /p:AssemblyVersion=$AssemblyVersion ` + /p:FileVersion=$FileVersion ` + /p:InformationalVersion=$InformationalVersion + } +} +``` + +**GitVersion.yml:** + +```yaml +mode: Mainline +branches: + main: + label: '' + develop: + label: 'beta' + feature: + label: 'alpha.{BranchName}' + release: + label: 'rc' + hotfix: + label: 'hotfix' +ignore: + sha: [] +``` + +## CI Build Number Versioning + +Leverage CI/CD build numbers: + +### GitHub Actions + +```powershell +Properties { + $BaseVersion = '1.0.0' + $BuildNumber = if ($env:GITHUB_RUN_NUMBER) { $env:GITHUB_RUN_NUMBER } else { '0' } + $GitSha = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.Substring(0, 7) } else { 'local' } + + # SemVer build identity and informational metadata + $Version = "$BaseVersion-ci.$BuildNumber" + $InformationalVersion = "$Version+$GitSha" +} + +Task Build { + Write-Host "Building version $Version" -ForegroundColor Cyan + Write-Host " Build: $BuildNumber" -ForegroundColor Gray + Write-Host " Commit: $GitSha" -ForegroundColor Gray + + exec { + dotnet build ` + /p:Version=$Version ` + /p:InformationalVersion=$InformationalVersion + } +} +``` + +**.github/workflows/build.yml:** + +```yaml +name: Build + +on: [push] + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get full history for versioning + + - name: Install psake + shell: pwsh + run: Install-Module -Name psake -Force + + - name: Build + shell: pwsh + run: | + Invoke-psake -buildFile .\psakefile.ps1 -taskList Build + env: + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_SHA: ${{ github.sha }} +``` + +### Azure Pipelines + +```powershell +Properties { + $BaseVersion = '1.0.0' + $BuildNumber = if ($env:BUILD_BUILDID) { $env:BUILD_BUILDID } else { '0' } + $Version = "$BaseVersion-ci.$BuildNumber" +} + +Task Build { + Write-Host "Building version $Version" -ForegroundColor Cyan + + # Update Azure Pipelines build number + if ($env:BUILD_BUILDNUMBER) { + Write-Host "##vso[build.updatebuildnumber]$Version" + } + + exec { + dotnet build /p:Version=$Version + } +} +``` + +**azure-pipelines.yml:** + +```yaml +trigger: + - main + +pool: + vmImage: 'windows-latest' + +name: '1.0.$(Rev:r)' + +steps: + - task: PowerShell@2 + displayName: 'Build' + inputs: + targetType: 'inline' + script: | + Install-Module -Name psake -Force + Invoke-psake -buildFile .\psakefile.ps1 -taskList Build +``` + +## Assembly Version Updates + +Automatically update project file versions: + +### Updating .NET Project Files + +```powershell +function Update-ProjectVersion { + param( + [string]$ProjectFile, + [string]$Version, + [string]$AssemblyVersion + ) + + if (-not (Test-Path $ProjectFile)) { + throw "Project file not found: $ProjectFile" + } + + [xml]$project = Get-Content $ProjectFile -Raw + $propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1 + if ($null -eq $propertyGroup) { + $propertyGroup = $project.CreateElement('PropertyGroup') + $project.Project.AppendChild($propertyGroup) | Out-Null + } + + $versions = @{ + Version = $Version + InformationalVersion = $Version + AssemblyVersion = $AssemblyVersion + FileVersion = $AssemblyVersion + } + foreach ($entry in $versions.GetEnumerator()) { + if ($null -eq $propertyGroup.($entry.Key)) { + $propertyGroup.AppendChild($project.CreateElement($entry.Key)) | Out-Null + } + $propertyGroup.($entry.Key) = $entry.Value + } + + $project.Save($ProjectFile) + Write-Host " Version updated to: $Version" -ForegroundColor Gray +} + + +Task UpdateProjectVersions { + Write-Host "Updating project versions..." -ForegroundColor Green + + $projects = Get-ChildItem -Path $SrcDir -Filter *.csproj -Recurse + foreach ($project in $projects) { + Update-ProjectVersion ` + -ProjectFile $project.FullName ` + -Version $Version ` + -AssemblyVersion "$MajorVersion.$MinorVersion.$PatchVersion.0" + } + + Write-Host "Updated $($projects.Count) project files" -ForegroundColor Green +} + +Task Build -depends UpdateProjectVersions { + exec { dotnet build -c Release } +} +``` + +### Updating AssemblyInfo.cs (Legacy) + +```powershell +function Update-AssemblyInfo { + param( + [string]$AssemblyInfoPath, + [string]$Version, + [string]$AssemblyVersion + ) + + if (-not (Test-Path $AssemblyInfoPath)) { + throw "AssemblyInfo.cs not found: $AssemblyInfoPath" + } + + $content = Get-Content $AssemblyInfoPath + $content = $content -replace '\[assembly: AssemblyVersion\(".*?"\)\]', "[assembly: AssemblyVersion(""$AssemblyVersion"")]" + $content = $content -replace '\[assembly: AssemblyFileVersion\(".*?"\)\]', "[assembly: AssemblyFileVersion(""$AssemblyVersion"")]" + $content = $content -replace '\[assembly: AssemblyInformationalVersion\(".*?"\)\]', "[assembly: AssemblyInformationalVersion(""$Version"")]" + + Set-Content -Path $AssemblyInfoPath -Value $content + + Write-Host " Updated to version: $Version" -ForegroundColor Gray +} + +Task UpdateAssemblyInfoFiles { + $assemblyInfoFiles = Get-ChildItem "$SrcDir/**/AssemblyInfo.cs" -Recurse + + foreach ($file in $assemblyInfoFiles) { + Update-AssemblyInfo ` + -AssemblyInfoPath $file.FullName ` + -Version $Version ` + -AssemblyVersion "$MajorVersion.$MinorVersion.$PatchVersion.0" + } +} +``` + +### Updating package.json (Node.js) + +```powershell +function Update-PackageVersion { + param( + [string]$PackageJsonPath, + [string]$Version + ) + + if (-not (Test-Path $PackageJsonPath)) { + throw "package.json not found: $PackageJsonPath" + } + + Write-Host "Updating package.json version to $Version" -ForegroundColor Green + + $package = Get-Content $PackageJsonPath | ConvertFrom-Json + $package.version = $Version + + $package | ConvertTo-Json -Depth 100 | Set-Content $PackageJsonPath + + Write-Host " Updated package.json" -ForegroundColor Gray +} + +Task UpdateNodeVersion { + $packageJson = Join-Path $ProjectRoot 'package.json' + Update-PackageVersion -PackageJsonPath $packageJson -Version $Version +} +``` + +## Complete Versioning Example + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $SrcDir = Join-Path $ProjectRoot 'src' + $BuildDir = Join-Path $ProjectRoot 'build/output' + + # Version configuration + $MajorVersion = 1 + $MinorVersion = 0 + $PatchVersion = 0 + + # CI/CD integration + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + $GitSha = Get-GitCommitSha + $Branch = Get-GitBranch + + # Determine version based on branch + $Version = Get-BuildVersion +} + +function Get-GitCommitSha { + try { + $sha = git rev-parse --short HEAD 2>$null + return if ($sha) { $sha } else { 'unknown' } + } + catch { + return 'unknown' + } +} + +function Get-GitBranch { + try { + if ($env:BRANCH_NAME) { + return $env:BRANCH_NAME + } + + $branch = git rev-parse --abbrev-ref HEAD 2>$null + return if ($branch) { $branch } else { 'unknown' } + } + catch { + return 'unknown' + } +} + +function Get-BuildVersion { + $baseVersion = "$MajorVersion.$MinorVersion.$PatchVersion" + + # Determine pre-release label + $preRelease = switch -Regex ($Branch) { + '^main$|^master$' { + return "$baseVersion-ci.$BuildNumber" + } + '^release/.*' { + # Release candidate + return "$baseVersion-rc.$BuildNumber" + } + '^develop$' { + # Beta release + return "$baseVersion-beta.$BuildNumber" + } + '^hotfix/.*' { + # Hotfix release + return "$baseVersion-hotfix.$BuildNumber" + } + default { + # Development/feature build + $safeBranch = $Branch -replace '[^a-zA-Z0-9]', '-' + return "$baseVersion-dev.$safeBranch.$BuildNumber" + } + } + + return $preRelease +} + +FormatTaskName { + param($taskName) + Write-Host "" + Write-Host "Executing: $taskName" -ForegroundColor Cyan + Write-Host ("=" * 80) -ForegroundColor Gray +} + +Task Default -depends Build + +Task ShowVersion { + Write-Host "" + Write-Host "Version Information" -ForegroundColor Cyan + Write-Host ("=" * 80) -ForegroundColor Gray + Write-Host " Version: $Version" -ForegroundColor White + Write-Host " Base: $MajorVersion.$MinorVersion.$PatchVersion" -ForegroundColor Gray + Write-Host " Build Number: $BuildNumber" -ForegroundColor Gray + Write-Host " Git SHA: $GitSha" -ForegroundColor Gray + Write-Host " Branch: $Branch" -ForegroundColor Gray + Write-Host ("=" * 80) -ForegroundColor Gray + Write-Host "" +} + +Task UpdateVersions -depends ShowVersion { + Write-Host "Updating project versions..." -ForegroundColor Green + + # Update .NET projects + $projects = Get-ChildItem -Path $SrcDir -Filter *.csproj -Recurse + + foreach ($project in $projects) { + [xml]$proj = Get-Content $project.FullName -Raw + + $propertyGroup = $proj.Project.PropertyGroup | Select-Object -First 1 + + if ($null -eq $propertyGroup.Version) { + $versionNode = $proj.CreateElement('Version') + $propertyGroup.AppendChild($versionNode) | Out-Null + } + + $propertyGroup.Version = $Version + + $proj.Save($project.FullName) + + Write-Host " Updated: $($project.Name)" -ForegroundColor Gray + } + + Write-Host "Version updates complete" -ForegroundColor Green +} + +Task Clean { + Write-Host "Cleaning build artifacts..." -ForegroundColor Green + + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + + New-Item -ItemType Directory -Path $BuildDir | Out-Null +} + +Task Build -depends UpdateVersions, Clean { + Write-Host "Building version $Version..." -ForegroundColor Green + + exec { + dotnet build $SrcDir ` + -c Release ` + -o $BuildDir ` + /p:Version=$Version ` + /p:InformationalVersion="$Version+$GitSha" + } + + Write-Host "Build complete: $BuildDir" -ForegroundColor Green +} + +Task Pack -depends Build { + Write-Host "Creating NuGet packages..." -ForegroundColor Green + + exec { + dotnet pack $SrcDir ` + -c Release ` + -o $BuildDir ` + --no-build ` + /p:PackageVersion=$Version + } + + $packages = Get-ChildItem "$BuildDir/*.nupkg" + Write-Host "Created $($packages.Count) package(s)" -ForegroundColor Green +} + +Task Tag { + param([string]$TagVersion) + + if ([string]::IsNullOrEmpty($TagVersion)) { + $TagVersion = "$MajorVersion.$MinorVersion.$PatchVersion" + } + + Write-Host "Creating git tag: v$TagVersion" -ForegroundColor Green + + # Ensure we're on main/master + if ($Branch -notmatch '^(main|master)$') { + throw "Tags should only be created from main/master branch (current: $Branch)" + } + + # Check if tag already exists + $existingTag = git tag -l "v$TagVersion" + if ($existingTag) { + throw "Tag v$TagVersion already exists" + } + + # Create annotated tag + exec { git tag -a "v$TagVersion" -m "Release $TagVersion" } + + Write-Host "Tag created: v$TagVersion" -ForegroundColor Green + Write-Host "Push tag with: git push origin v$TagVersion" -ForegroundColor Yellow +} +``` + +## Best Practices + +1. **Use semantic versioning** - Follow MAJOR.MINOR.PATCH conventions +2. **Tag releases in git** - Create git tags for all releases +3. **Include build metadata** - Add commit SHA and build number to informational version +4. **Automate version bumps** - Don't manually edit version numbers +5. **Use pre-release labels** - Distinguish beta, alpha, and RC versions +6. **Keep version in one place** - Single source of truth for version number +7. **Version all artifacts** - DLLs, packages, containers should all have same version +8. **Document version strategy** - Team should understand versioning scheme +9. **Test version updates** - Ensure version updates don't break builds +10. **Archive version history** - Maintain changelog with version history + +## See Also + +- [GitHub Actions](/docs/ci-examples/github-actions) - CI/CD with versioning +- [Azure Pipelines](/docs/ci-examples/azure-pipelines) - Azure DevOps versioning +- [Node.js Builds](/docs/build-types/nodejs) - Versioning Node.js packages +- [.NET Solution Builds](/docs/build-types/dot-net-solution) - .NET versioning +- [Organizing Large Scripts](/docs/best-practices/organizing-large-scripts) - Version utilities organization diff --git a/sidebars.ts b/sidebars.ts index 7d918d7..5de17c9 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -60,6 +60,17 @@ const sidebars: SidebarsConfig = { 'build-types/docker' ] }, + { + type: 'category', + label: 'Best Practices', + items: [ + 'best-practices/organizing-large-scripts', + 'best-practices/environment-management', + 'best-practices/secret-management', + 'best-practices/testing-build-scripts', + 'best-practices/versioning-strategy' + ] + }, { type: 'category', label: 'CI/CD Integration',