From ca2491d1dc54823fb7d8e297319713b08dd2094e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 03:53:31 +0000 Subject: [PATCH] Add Best Practices documentation section (Phase 2.1) This commit completes Phase 2.1 of the documentation expansion plan by adding comprehensive best practices documentation for psake build scripts. New Documentation Added: - Best Practices category (_category_.json) - Organizing Large Scripts (organizing-large-scripts.md) - File structure patterns (by category and build type) - Modular task files with complete examples - Using Include effectively with path validation - Shared utilities (file operations, logging, versioning) - Complete large project example - Environment Management (environment-management.md) - Environment configuration patterns (inline, external files, JSON/YAML) - Environment-specific properties for dev/staging/prod - Conditional task execution using preconditions - Complete environment management example - CI/CD integration (GitHub Actions, Azure Pipelines) - Secret Management (secret-management.md) - Environment variable patterns - Secure strings (PowerShell DPAPI) - Azure Key Vault integration - AWS Secrets Manager integration - HashiCorp Vault integration - Security best practices (never commit secrets, avoid logging) - Secure certificate handling - Testing Build Scripts (testing-build-scripts.md) - Pester test setup and structure - Testing task dependencies - Mocking external commands (dotnet, az, AWS CLI) - Integration tests - Error handling tests - Test-friendly build script patterns - CI/CD integration - Build Versioning Strategies (versioning-strategy.md) - Semantic versioning (SemVer) with pre-release labels - Git-based versioning from tags - GitVersion tool integration - CI build number versioning (GitHub Actions, Azure Pipelines) - Assembly version updates (.NET, Node.js) - Complete versioning example Updated: - sidebars.ts: Added Best Practices section between Build Types and CI Examples with all 5 new documentation pages properly linked All documentation follows established patterns: - Proper frontmatter with title and description - Quick Start section with basic examples - Complete working examples with full psakefile.ps1 code - Multiple patterns and approaches - Cross-references to related documentation - "See Also" sections for navigation - Comprehensive, production-ready code samples --- docs/best-practices/_category_.json | 8 + docs/best-practices/environment-management.md | 818 ++++++++++++++++++ .../organizing-large-scripts.md | 816 +++++++++++++++++ docs/best-practices/secret-management.md | 764 ++++++++++++++++ docs/best-practices/testing-build-scripts.md | 751 ++++++++++++++++ docs/best-practices/versioning-strategy.md | 728 ++++++++++++++++ sidebars.ts | 11 + 7 files changed, 3896 insertions(+) create mode 100644 docs/best-practices/_category_.json create mode 100644 docs/best-practices/environment-management.md create mode 100644 docs/best-practices/organizing-large-scripts.md create mode 100644 docs/best-practices/secret-management.md create mode 100644 docs/best-practices/testing-build-scripts.md create mode 100644 docs/best-practices/versioning-strategy.md 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..a411242 --- /dev/null +++ b/docs/best-practices/environment-management.md @@ -0,0 +1,818 @@ +--- +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' } + + # Configuration mode + $Configuration = switch ($Environment) { + 'dev' { 'Debug' } + 'staging' { 'Release' } + 'prod' { 'Release' } + } + + # Database connection strings + $DatabaseServer = switch ($Environment) { + 'dev' { 'localhost' } + 'staging' { 'db-staging.internal' } + 'prod' { 'db-prod.internal' } + } + + # API endpoints + $ApiUrl = switch ($Environment) { + 'dev' { 'http://localhost:5000' } + 'staging' { 'https://api-staging.example.com' } + 'prod' { 'https://api.example.com' } + } + + # Feature flags + $EnableTelemetry = switch ($Environment) { + 'dev' { $false } + 'staging' { $true } + 'prod' { $true } + } + + # Logging level + $LogLevel = switch ($Environment) { + 'dev' { 'Debug' } + 'staging' { 'Information' } + 'prod' { 'Warning' } + } +} + +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 +Properties { + $ProjectRoot = $PSScriptRoot + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $ConfigDir = Join-Path $ProjectRoot 'build/config' +} + +# Load environment-specific configuration +$envConfig = Join-Path $ConfigDir "${Environment}.ps1" + +if (-not (Test-Path $envConfig)) { + throw "Environment configuration not found: $envConfig. Valid environments: dev, staging, prod" +} + +Write-Host "Loading configuration for: $Environment" -ForegroundColor Cyan +Include $envConfig + +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 } +} + +Task Deploy -depends Build { + if ($RequireApproval) { + $confirmation = Read-Host "Deploy to $Environment? This is a PRODUCTION environment! (yes/no)" + if ($confirmation -ne 'yes') { + Write-Host "Deployment cancelled" -ForegroundColor Yellow + return + } + } + + switch ($DeploymentTarget) { + 'local' { Invoke-psake -taskList Deploy:Local } + 'azure-staging' { Invoke-psake -taskList Deploy:Azure } + 'azure-production' { Invoke-psake -taskList Deploy:Azure } + 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 + } + } +} +``` + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $ConfigFile = Join-Path $ProjectRoot 'build/config/environments.json' +} + +# Load and parse configuration +if (-not (Test-Path $ConfigFile)) { + throw "Configuration file not found: $ConfigFile" +} + +$allConfigs = Get-Content $ConfigFile | ConvertFrom-Json +$config = $allConfigs.$Environment + +if ($null -eq $config) { + throw "Configuration for environment '$Environment' not found in $ConfigFile" +} + +# Extract configuration values +Properties { + $Configuration = $config.configuration + $DatabaseServer = $config.database.server + $DatabaseName = $config.database.name + $DatabasePort = $config.database.port + $ApiUrl = $config.services.api + $AuthUrl = $config.services.auth + $EnableCaching = $config.features.caching + $EnableTelemetry = $config.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 SkipTestsInDev -precondition { $Environment -ne 'dev' } { + Invoke-psake -taskList RunTests +} +``` + +### Environment-Specific Task Lists + +```powershell +Properties { + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +} + +Task Default -depends Build + +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 +} + +# Automatically select task based on environment +Task Auto { + switch ($Environment) { + 'dev' { Invoke-psake -taskList Dev } + 'staging' { Invoke-psake -taskList Staging } + 'prod' { Invoke-psake -taskList Production } + default { throw "Unknown environment: $Environment" } + } +} +``` + +### 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) { + Write-Host "Instrumenting for telemetry..." -ForegroundColor Cyan + # Add telemetry instrumentation + } +} +``` + +## Complete Environment Management Example + +Here's a comprehensive example combining all patterns: + +**psakefile.ps1:** + +```powershell +Properties { + # Base properties + $ProjectRoot = $PSScriptRoot + $SrcDir = Join-Path $ProjectRoot 'src' + $BuildDir = Join-Path $ProjectRoot 'build/output' + $ConfigDir = Join-Path $ProjectRoot 'build/config' + + # Environment detection + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + + # Validate environment + $validEnvironments = @('dev', 'staging', 'prod') + if ($Environment -notin $validEnvironments) { + throw "Invalid environment: $Environment. Valid options: $($validEnvironments -join ', ')" + } +} + +# Load environment-specific configuration +$envConfigFile = Join-Path $ConfigDir "${Environment}.ps1" +if (Test-Path $envConfigFile) { + Write-Host "Loading environment configuration: $Environment" -ForegroundColor Cyan + Include $envConfigFile +} else { + throw "Environment configuration not found: $envConfigFile" +} + +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 +} + +Task Deploy -depends Package { + if ($RequireApproval) { + Write-Warning "Deploying to $Environment environment!" + $confirmation = Read-Host "Are you sure you want to continue? (yes/no)" + + if ($confirmation -ne 'yes') { + Write-Host "Deployment cancelled" -ForegroundColor Yellow + return + } + } + + Write-Host "Deploying to $DeploymentTarget..." -ForegroundColor Green + + switch ($DeploymentTarget) { + 'local' { + Copy-Item "$BuildDir/*" -Destination "C:\Deploy\$Environment" -Recurse -Force + } + 'azure-staging' { + exec { + az webapp deployment source config-zip ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src "$BuildDir/*.zip" + } + } + 'azure-production' { + exec { + az webapp deployment source config-zip ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src "$BuildDir/*.zip" + } + + # Run health checks after production deployment + Start-Sleep -Seconds 10 + Invoke-psake -taskList HealthCheck + } + 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: Build and Deploy to Staging + shell: pwsh + run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy + env: + BUILD_ENV: staging + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS_STAGING }} + + 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: Build and Deploy to Production + shell: pwsh + run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy + env: + BUILD_ENV: prod + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS_PROD }} +``` + +## 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** - Add confirmation prompts for production deployments +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 { + Write-Host "BUILD_ENV: $($env:BUILD_ENV)" -ForegroundColor Yellow + Write-Host "Environment: $Environment" -ForegroundColor Yellow + Write-Host "Config File: $envConfigFile" -ForegroundColor Yellow + Write-Host "File Exists: $(Test-Path $envConfigFile)" -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..49fccc2 --- /dev/null +++ b/docs/best-practices/organizing-large-scripts.md @@ -0,0 +1,816 @@ +--- +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 + $TasksDir = Join-Path $ProjectRoot 'build/tasks' +} + +# Include modular task files +Include (Join-Path $TasksDir 'build.ps1') +Include (Join-Path $TasksDir 'test.ps1') +Include (Join-Path $TasksDir 'deploy.ps1') + +Task Default -depends Build, Test +``` + +## File Structure Patterns + +### Pattern 1: Tasks by Category + +Organize tasks by functional area: + +``` +my-project/ +├── build/ +│ ├── tasks/ +│ │ ├── compile.ps1 # Compilation tasks +│ │ ├── test.ps1 # Testing tasks +│ │ ├── package.ps1 # Packaging tasks +│ │ ├── deploy.ps1 # Deployment tasks +│ │ └── cleanup.ps1 # Cleanup tasks +│ ├── utils/ +│ │ ├── fileops.ps1 # File operations +│ │ ├── versioning.ps1 # Version management +│ │ └── logging.ps1 # Custom logging +│ └── config/ +│ ├── dev.ps1 # Development config +│ ├── staging.ps1 # Staging config +│ └── prod.ps1 # Production config +├── psakefile.ps1 # Main orchestrator +└── build.ps1 # Bootstrap script +``` + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $TasksDir = Join-Path $BuildRoot 'tasks' + $UtilsDir = Join-Path $BuildRoot 'utils' + $ConfigDir = Join-Path $BuildRoot 'config' + + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $Configuration = 'Release' +} + +# Load utilities first (order matters) +Include (Join-Path $UtilsDir 'logging.ps1') +Include (Join-Path $UtilsDir 'fileops.ps1') +Include (Join-Path $UtilsDir 'versioning.ps1') + +# Load environment-specific configuration +Include (Join-Path $ConfigDir "${Environment}.ps1") + +# Load task modules +Include (Join-Path $TasksDir 'compile.ps1') +Include (Join-Path $TasksDir 'test.ps1') +Include (Join-Path $TasksDir 'package.ps1') +Include (Join-Path $TasksDir 'deploy.ps1') +Include (Join-Path $TasksDir 'cleanup.ps1') + +FormatTaskName { + param($taskName) + Write-LogHeader "Executing: $taskName" +} + +Task Default -depends Build + +Task Build -depends Compile, Test, Package + +Task CI -depends Build, Deploy + +Task Full -depends Clean, Build, Deploy +``` + +### Pattern 2: Tasks by Build Type + +For projects with multiple build types (library, service, tools): + +``` +my-project/ +├── build/ +│ ├── tasks/ +│ │ ├── library/ +│ │ │ ├── build.ps1 +│ │ │ ├── test.ps1 +│ │ │ └── publish.ps1 +│ │ ├── service/ +│ │ │ ├── build.ps1 +│ │ │ ├── docker.ps1 +│ │ │ └── deploy.ps1 +│ │ └── tools/ +│ │ ├── build.ps1 +│ │ └── package.ps1 +│ └── shared/ +│ └── common.ps1 +└── psakefile.ps1 +``` + +**psakefile.ps1:** + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $BuildType = 'all' # Options: library, service, tools, all +} + +# Load shared utilities +Include (Join-Path $BuildRoot 'shared/common.ps1') + +# Conditionally load build type tasks +if ($BuildType -eq 'library' -or $BuildType -eq 'all') { + Include (Join-Path $BuildRoot 'tasks/library/build.ps1') + Include (Join-Path $BuildRoot 'tasks/library/test.ps1') + Include (Join-Path $BuildRoot 'tasks/library/publish.ps1') +} + +if ($BuildType -eq 'service' -or $BuildType -eq 'all') { + Include (Join-Path $BuildRoot 'tasks/service/build.ps1') + Include (Join-Path $BuildRoot 'tasks/service/docker.ps1') + Include (Join-Path $BuildRoot 'tasks/service/deploy.ps1') +} + +if ($BuildType -eq 'tools' -or $BuildType -eq 'all') { + Include (Join-Path $BuildRoot 'tasks/tools/build.ps1') + Include (Join-Path $BuildRoot 'tasks/tools/package.ps1') +} + +Task Default -depends Build + +Task Build { + if ($BuildType -eq 'library' -or $BuildType -eq 'all') { + Invoke-psake -taskList Library:Build + } + if ($BuildType -eq 'service' -or $BuildType -eq 'all') { + Invoke-psake -taskList Service:Build + } + if ($BuildType -eq 'tools' -or $BuildType -eq 'all') { + Invoke-psake -taskList Tools:Build + } +} +``` + +## Modular Task Files + +Break down complex builds into focused, reusable task files. + +### Example: Compilation Tasks + +**build/tasks/compile.ps1:** + +```powershell +Properties { + # These can reference properties from main psakefile + $SrcDir = Join-Path $ProjectRoot 'src' + $BuildDir = Join-Path $ProjectRoot 'build/output' +} + +Task Compile -depends Clean { + Write-Host "Compiling solution..." -ForegroundColor Green + + $solutionFile = Get-ChildItem "$SrcDir/*.sln" | Select-Object -First 1 + + if (-not $solutionFile) { + throw "No solution file found in $SrcDir" + } + + exec { + dotnet build $solutionFile.FullName ` + -c $Configuration ` + -o $BuildDir ` + /p:Version=$Version ` + --no-incremental + } + + Write-Host "Compilation complete: $BuildDir" -ForegroundColor Green +} + +Task CompileDebug { + $script:Configuration = 'Debug' + Invoke-psake -taskList Compile +} + +Task CompileRelease { + $script:Configuration = 'Release' + Invoke-psake -taskList Compile +} + +Task Restore { + Write-Host "Restoring NuGet packages..." -ForegroundColor Green + + $solutionFile = Get-ChildItem "$SrcDir/*.sln" | Select-Object -First 1 + exec { dotnet restore $solutionFile.FullName } +} + +Task Clean { + Write-Host "Cleaning build artifacts..." -ForegroundColor Green + + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + Write-Host " Removed: $BuildDir" -ForegroundColor Gray + } + + # Clean obj and bin directories + Get-ChildItem $SrcDir -Include bin,obj -Recurse -Directory | ForEach-Object { + Remove-Item $_.FullName -Recurse -Force + Write-Host " Removed: $($_.FullName)" -ForegroundColor Gray + } +} +``` + +### Example: Testing Tasks + +**build/tasks/test.ps1:** + +```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 + $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 +Properties { + $DeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'dev' } + $DeploymentDir = Join-Path $ProjectRoot 'deployment' +} + +Task Deploy -depends Package -precondition { $Environment -ne 'dev' } { + Write-Host "Deploying to $DeployTarget..." -ForegroundColor Green + + switch ($DeployTarget) { + 'azure' { Invoke-psake -taskList Deploy:Azure } + 'aws' { Invoke-psake -taskList Deploy:AWS } + 'local' { Invoke-psake -taskList Deploy:Local } + default { throw "Unknown deploy target: $DeployTarget" } + } +} + +Task Deploy:Azure { + Write-Host "Deploying to Azure..." -ForegroundColor Green + + $webAppName = $AzureWebAppName + $resourceGroup = $AzureResourceGroup + + if ([string]::IsNullOrEmpty($webAppName) -or [string]::IsNullOrEmpty($resourceGroup)) { + throw "Azure configuration is incomplete" + } + + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + + exec { + az webapp deployment source config-zip ` + --resource-group $resourceGroup ` + --name $webAppName ` + --src $packageFile.FullName + } + + Write-Host "Deployed to Azure: https://${webAppName}.azurewebsites.net" -ForegroundColor Green +} + +Task Deploy:AWS { + Write-Host "Deploying to AWS..." -ForegroundColor Green + + # AWS deployment logic here + throw "AWS deployment not yet implemented" +} + +Task Deploy:Local { + Write-Host "Deploying to local environment..." -ForegroundColor Green + + $targetDir = Join-Path $DeploymentDir $Environment + + if (Test-Path $targetDir) { + Remove-Item $targetDir -Recurse -Force + } + + Copy-Item $BuildDir -Destination $targetDir -Recurse + + Write-Host "Deployed to: $targetDir" -ForegroundColor Green +} +``` + +## Using Include Effectively + +The `Include` function allows you to split build logic across multiple files. + +### Include with Path Validation + +```powershell +Properties { + $BuildRoot = Join-Path $PSScriptRoot 'build' +} + +# Helper function to safely include files +function Include-TaskFile { + param([string]$RelativePath) + + $fullPath = Join-Path $BuildRoot $RelativePath + + if (-not (Test-Path $fullPath)) { + throw "Task file not found: $fullPath" + } + + Include $fullPath +} + +# Include task files with validation +Include-TaskFile 'tasks/build.ps1' +Include-TaskFile 'tasks/test.ps1' +Include-TaskFile 'tasks/deploy.ps1' +``` + +### Dynamic Includes Based on Configuration + +```powershell +Properties { + $ProjectType = 'dotnet' # Options: dotnet, nodejs, docker + $TasksDir = Join-Path $PSScriptRoot 'build/tasks' +} + +# Include common tasks +Include (Join-Path $TasksDir 'common.ps1') + +# Include project-type specific tasks +$projectTaskFile = Join-Path $TasksDir "${ProjectType}.ps1" +if (Test-Path $projectTaskFile) { + Include $projectTaskFile +} else { + throw "No task file found for project type: $ProjectType" +} + +# Include optional tasks if they exist +$optionalTasks = @('docker.ps1', 'kubernetes.ps1', 'terraform.ps1') +foreach ($taskFile in $optionalTasks) { + $fullPath = Join-Path $TasksDir $taskFile + if (Test-Path $fullPath) { + Write-Host "Loading optional tasks: $taskFile" -ForegroundColor Gray + Include $fullPath + } +} +``` + +### Include Order Matters + +```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 +} + +# Export utilities (make them available to other scripts) +Export-ModuleMember -Function @( + 'Remove-DirectorySafe', + 'New-DirectorySafe', + 'Copy-DirectoryContents', + 'Get-FileHash256' +) +``` + +### Example: Logging Utility + +**build/utils/logging.ps1:** + +```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 +} + +# Export utilities +Export-ModuleMember -Function @( + 'Write-LogHeader', + 'Write-LogSection', + 'Write-LogInfo', + 'Write-LogSuccess', + 'Write-LogWarning', + 'Write-LogError', + 'Write-LogStep' +) +``` + +### Example: Versioning Utility + +**build/utils/versioning.ps1:** + +```powershell +# Version management utilities + +function Get-GitVersion { + <# + .SYNOPSIS + Gets version information from git tags and commits + #> + + try { + # Get latest tag + $tag = git describe --tags --abbrev=0 2>$null + + if ([string]::IsNullOrEmpty($tag)) { + return "1.0.0" + } + + # Parse semantic version + if ($tag -match '^v?(\d+)\.(\d+)\.(\d+)') { + $major = $matches[1] + $minor = $matches[2] + $patch = $matches[3] + + # Get commits since tag + $commitsSinceTag = git rev-list "$tag..HEAD" --count 2>$null + + if ($commitsSinceTag -gt 0) { + # Bump patch version + $patch = [int]$patch + 1 + return "$major.$minor.$patch-dev.$commitsSinceTag" + } + + return "$major.$minor.$patch" + } + + return "1.0.0" + } + catch { + Write-Warning "Failed to get git version: $_" + return "1.0.0" + } +} + +function Get-BuildVersion { + param( + [string]$BaseVersion = "1.0.0", + [string]$BuildNumber = $null + ) + + if ([string]::IsNullOrEmpty($BuildNumber)) { + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { "0" } + } + + if ($BaseVersion -match '^(\d+)\.(\d+)\.(\d+)') { + $major = $matches[1] + $minor = $matches[2] + return "$major.$minor.$BuildNumber" + } + + return "$BaseVersion.$BuildNumber" +} + +function Set-AssemblyVersion { + param( + [string]$ProjectFile, + [string]$Version + ) + + if (-not (Test-Path $ProjectFile)) { + throw "Project file not found: $ProjectFile" + } + + [xml]$project = Get-Content $ProjectFile + + $propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1 + + if ($null -eq $propertyGroup.Version) { + $versionNode = $project.CreateElement("Version") + $propertyGroup.AppendChild($versionNode) | Out-Null + } + + $propertyGroup.Version = $Version + + $project.Save($ProjectFile) + + Write-Host "Updated version to $Version in $ProjectFile" -ForegroundColor Green +} + +Export-ModuleMember -Function @( + 'Get-GitVersion', + 'Get-BuildVersion', + 'Set-AssemblyVersion' +) +``` + +## Complete Example: Large Project + +Here's a complete example combining all patterns: + +**psakefile.ps1:** + +```powershell +Properties { + # Base paths + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $SrcDir = Join-Path $ProjectRoot 'src' + $TestDir = Join-Path $ProjectRoot 'tests' + $BuildDir = Join-Path $ProjectRoot 'build/output' + + # Configuration + $Configuration = if ($env:BUILD_CONFIGURATION) { $env:BUILD_CONFIGURATION } else { 'Debug' } + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + + # Versioning + $Version = Get-GitVersion + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } +} + +# Load utilities (order matters!) +Include (Join-Path $BuildRoot 'utils/logging.ps1') +Include (Join-Path $BuildRoot 'utils/fileops.ps1') +Include (Join-Path $BuildRoot 'utils/versioning.ps1') + +# Load environment configuration +$envConfig = Join-Path $BuildRoot "config/${Environment}.ps1" +if (Test-Path $envConfig) { + Include $envConfig +} + +# Load task modules +Include (Join-Path $BuildRoot 'tasks/compile.ps1') +Include (Join-Path $BuildRoot 'tasks/test.ps1') +Include (Join-Path $BuildRoot 'tasks/package.ps1') +Include (Join-Path $BuildRoot 'tasks/deploy.ps1') +Include (Join-Path $BuildRoot 'tasks/cleanup.ps1') + +# Custom task formatter +FormatTaskName { + param($taskName) + Write-LogHeader "Task: $taskName" +} + +# Main orchestration tasks +Task Default -depends Build + +Task Build -depends Restore, Compile, Test { + Write-LogSuccess "Build completed successfully" +} + +Task CI -depends Build, Package { + Write-LogSuccess "CI build completed" +} + +Task Release -depends Clean, Build, Package, Deploy { + Write-LogSuccess "Release completed" +} + +Task Full -depends Clean, Restore, Compile, TestWithCoverage, Package, Deploy { + Write-LogSuccess "Full build and deployment completed" +} +``` + +## Best Practices Summary + +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..4d55a75 --- /dev/null +++ b/docs/best-practices/secret-management.md @@ -0,0 +1,764 @@ +--- +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" + } + + # Use secrets (they won't appear in logs with -errorMessage) + exec { + dotnet publish --api-key $ApiKey + } -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 + + $username = $DeploymentCredential.UserName + $password = $DeploymentCredential.GetNetworkCredential().Password + + # Use credentials for deployment + exec { + msdeploy -verb:sync ` + -source:package="$BuildDir\package.zip" ` + -dest:auto,computerName="https://server.example.com:8172/msdeploy.axd",userName=$username,password=$password ` + -allowUntrusted + } -errorMessage "Deployment failed" +} +``` + +### Pattern 3: Azure Key Vault + +For Azure-hosted secrets: + +```powershell +Properties { + $ProjectRoot = $PSScriptRoot + $KeyVaultName = $env:AZURE_KEYVAULT_NAME + + # Azure authentication + $AzureTenantId = $env:AZURE_TENANT_ID + $AzureClientId = $env:AZURE_CLIENT_ID + $AzureClientSecret = $env:AZURE_CLIENT_SECRET +} + +Task AzureLogin { + Write-Host "Authenticating with Azure..." -ForegroundColor Green + + if ([string]::IsNullOrEmpty($AzureClientSecret)) { + # Interactive login for local development + exec { az login } + } else { + # Service principal login for CI/CD + exec { + az login --service-principal ` + --tenant $AzureTenantId ` + --username $AzureClientId ` + --password $AzureClientSecret + } + } + + 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, GetSecretsFromKeyVault { + Write-Host "Deploying with Key Vault secrets..." -ForegroundColor Green + + # Use the secrets retrieved from Key Vault + $connectionString = "Server=db.example.com;Database=MyApp;User Id=admin;Password=$DatabasePassword;" + + # Update configuration with secrets + $appSettingsPath = Join-Path $BuildDir 'appsettings.json' + $appSettings = Get-Content $appSettingsPath | ConvertFrom-Json + + $appSettings.ConnectionStrings.DefaultConnection = $connectionString + $appSettings.ExternalServices.ApiKey = $ApiKey + + $appSettings | ConvertTo-Json -Depth 10 | Set-Content $appSettingsPath + + # Deploy application + exec { az webapp deployment source config-zip --src "$BuildDir/package.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 CreateAwsSecret { + param( + [string]$SecretName = 'myapp/prod', + [string]$SecretValue + ) + + Write-Host "Creating secret in AWS Secrets Manager..." -ForegroundColor Green + + if ([string]::IsNullOrEmpty($SecretValue)) { + # Interactive input + $SecretValue = Read-Host "Enter secret value" -AsSecureString + $SecretValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecretValue) + ) + } + + # Create or update secret + try { + exec { + aws secretsmanager create-secret ` + --name $SecretName ` + --secret-string $SecretValue ` + --region $AwsRegion + } + Write-Host "Secret created: $SecretName" -ForegroundColor Green + } + catch { + # If secret exists, update it + exec { + aws secretsmanager update-secret ` + --secret-id $SecretName ` + --secret-string $SecretValue ` + --region $AwsRegion + } + Write-Host "Secret updated: $SecretName" -ForegroundColor Green + } +} + +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 --api-key $ApiKey } +} +``` + +**Good - Secrets redacted:** + +```powershell +Task Deploy { + Write-Host "Using API key: [REDACTED]" -ForegroundColor Gray + + # Use custom error message to avoid exposing secrets + exec { + dotnet publish --api-key $ApiKey + } -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 { + $CertificatePath = Join-Path $ProjectRoot 'certs/signing.pfx' + $CertificatePassword = $env:SIGNING_CERT_PASSWORD +} + +Task SignAssemblies -depends Build { + Write-Host "Signing assemblies..." -ForegroundColor Green + + if ([string]::IsNullOrEmpty($CertificatePassword)) { + throw "SIGNING_CERT_PASSWORD environment variable is required" + } + + if (-not (Test-Path $CertificatePath)) { + throw "Certificate not found: $CertificatePath" + } + + # Sign assemblies + $assemblies = Get-ChildItem "$BuildDir/*.dll" -Recurse + + foreach ($assembly in $assemblies) { + exec { + signtool sign /f $CertificatePath ` + /p $CertificatePassword ` + /t http://timestamp.digicert.com ` + /fd SHA256 ` + $assembly.FullName + } -errorMessage "Failed to sign $($assembly.Name)" + + Write-Host " Signed: $($assembly.Name)" -ForegroundColor Gray + } +} +``` + +### Cleanup Secrets After Use + +```powershell +Task Deploy { + try { + # Retrieve secrets + $apiKey = $env:API_KEY + $dbPassword = $env:DB_PASSWORD + + # Use secrets + exec { dotnet publish --api-key $apiKey } + + # Create temporary connection string + $connectionString = "Server=db;Database=MyApp;Password=$dbPassword;" + # Use connection string... + } + finally { + # Clear sensitive variables + $apiKey = $null + $dbPassword = $null + $connectionString = $null + + # Force garbage collection + [System.GC]::Collect() + } +} +``` + +## 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 { + if ($UseKeyVault) { + Invoke-psake -taskList GetSecretsFromKeyVault + } else { + Write-Host "Using secrets from environment variables" -ForegroundColor Gray + } +} + +Task GetSecretsFromKeyVault { + Write-Host "Retrieving secrets from Azure Key Vault..." -ForegroundColor Green + + # Login to Azure + exec { + az login --service-principal ` + --tenant $env:AZURE_TENANT_ID ` + --username $env:AZURE_CLIENT_ID ` + --password $env:AZURE_CLIENT_SECRET + } + + # Retrieve secrets + $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 + + $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)" + } + + # Clear sensitive data + $script:NuGetApiKey = $null + + Write-Host "Publishing complete" -ForegroundColor Green +} +``` + +## 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 $KeyVaultName ` + --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..564bc83 --- /dev/null +++ b/docs/best-practices/testing-build-scripts.md @@ -0,0 +1,751 @@ +--- +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 is valid PowerShell' { + { . $BuildFile } | Should -Not -Throw + } + + It 'Default task executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -nologo + $result | Should -Be $true + } +} +``` + +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 has no syntax errors' { + { $null = & $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 | Should -Be $true + } + + 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:ExecutedTasks = @() + + # Mock exec to track task execution + Mock -ModuleName psake -CommandName 'exec' -MockWith { + param($cmd, $errorMessage) + # Track execution instead of actually running + return $true + } + } + + It 'Build depends on Compile' { + # Load build file + . $BuildFile + + # Get task dependencies + $buildTask = Get-PSakeScriptTask -taskName 'Build' + $buildTask.DependsOn | Should -Contain 'Compile' + } + + It 'Deploy depends on Build and Test' { + . $BuildFile + + $deployTask = Get-PSakeScriptTask -taskName 'Deploy' + $deployTask.DependsOn | Should -Contain 'Build' + $deployTask.DependsOn | Should -Contain 'Test' + } + + It 'Tasks execute in correct order' { + $executionOrder = @() + + # Override task execution to track order + function Track-TaskExecution { + param($taskName) + $script:executionOrder += $taskName + } + + # Run build and track execution + # This requires modifying the build script to support test mode + $env:PSAKE_TEST_MODE = 'true' + Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + $env:PSAKE_TEST_MODE = $null + + # Verify order + $executionOrder.IndexOf('Compile') | Should -BeLessThan $executionOrder.IndexOf('Build') + $executionOrder.IndexOf('Build') | Should -BeLessThan $executionOrder.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 { + param($Command) + + switch ($Command) { + 'build' { + Write-Output "Build succeeded" + return 0 + } + 'test' { + Write-Output "Tests passed: 50 passed, 0 failed" + return 0 + } + 'publish' { + Write-Output "Publish succeeded" + return 0 + } + default { + return 0 + } + } + } -ModuleName psake + } + + It 'Compile task calls dotnet build' { + Invoke-psake -buildFile $BuildFile -taskList Compile -nologo + + # Verify dotnet build was called + Should -Invoke -CommandName 'dotnet' -ParameterFilter { + $Command -eq 'build' + } -Times 1 -ModuleName psake + } + + It 'Test task calls dotnet test' { + Invoke-psake -buildFile $BuildFile -taskList Test -nologo + + Should -Invoke -CommandName 'dotnet' -ParameterFilter { + $Command -eq 'test' + } -Times 1 -ModuleName psake + } +} +``` + +### 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 { + param($Command) + + if ($Command -eq 'login') { + return @" +[ + { + "cloudName": "AzureCloud", + "id": "12345678-1234-1234-1234-123456789012", + "state": "Enabled" + } +] +"@ + } + + if ($Command -eq 'webapp') { + return "Deployment successful" + } + + return "" + } + } + + It 'Deploy task authenticates with Azure' { + Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + + Should -Invoke -CommandName 'az' -ParameterFilter { + $Command -eq 'login' + } -Times 1 + } + + It 'Deploy task deploys to Azure Web App' { + Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + + Should -Invoke -CommandName 'az' -ParameterFilter { + $Command -eq 'webapp' + } -Times 1 + } +} +``` + +## Testing Properties and Parameters + +Validate that properties are set correctly: + +```powershell +Describe 'Build Properties' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + } + + It 'Default configuration is Debug' { + # Load build file + . $BuildFile + + # Check property + $psake.context.peek().config.Configuration | Should -Be 'Debug' + } + + It 'Configuration can be overridden' { + $parameters = @{ + Configuration = 'Release' + } + + Invoke-psake -buildFile $BuildFile ` + -parameters $parameters ` + -taskList ShowConfig ` + -nologo + + # Verify configuration was set + # This requires the build script to expose configuration + } + + It 'Environment defaults to dev' { + . $BuildFile + + $psake.context.peek().config.Environment | Should -Be 'dev' + } +} +``` + +## 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 | Should -Be $true + } + + 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' { + $params = @{ Configuration = 'Debug' } + $result = Invoke-psake -buildFile $BuildFile -parameters $params -nologo + $result | Should -Be $true + } + + It 'Release build succeeds' { + $params = @{ Configuration = 'Release' } + $result = Invoke-psake -buildFile $BuildFile -parameters $params -nologo + $result | Should -Be $true + } + } + + 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 dotnet to return error + Mock -CommandName 'dotnet' -MockWith { + Write-Error "Compilation failed" + return 1 + } + + $result = Invoke-psake -buildFile $BuildFile -taskList Compile -nologo + $result | Should -Be $false + } + + It 'Build fails when tests fail' { + Mock -CommandName 'dotnet' -MockWith { + param($Command) + + if ($Command -eq 'test') { + Write-Error "Tests failed" + return 1 + } + return 0 + } + + $result = Invoke-psake -buildFile $BuildFile -taskList Test -nologo + $result | Should -Be $false + } + + It 'Build validates required secrets' { + # Clear environment variables + $originalApiKey = $env:API_KEY + $env:API_KEY = $null + + try { + { Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo } | Should -Throw + } + 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 is valid PowerShell' { + { . $BuildFile } | Should -Not -Throw + } + + It 'Build tasks directory exists' { + $tasksDir = Join-Path $ProjectRoot 'build/tasks' + Test-Path $tasksDir | Should -Be $true + } + } + + Context 'Task Definitions' { + BeforeAll { + . $BuildFile + } + + It 'Defines Default task' { + $task = Get-PSakeScriptTask -taskName 'Default' + $task | Should -Not -BeNullOrEmpty + } + + It 'Defines Build task' { + $task = Get-PSakeScriptTask -taskName 'Build' + $task | Should -Not -BeNullOrEmpty + } + + It 'Defines Test task' { + $task = Get-PSakeScriptTask -taskName 'Test' + $task | Should -Not -BeNullOrEmpty + } + + It 'Test task depends on Build' { + $task = Get-PSakeScriptTask -taskName 'Test' + $task.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 | Should -Be $true + } + + It 'Build task executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -taskList Build -nologo + $result | Should -Be $true + } + + It 'Full pipeline executes successfully' { + $result = Invoke-psake -buildFile $BuildFile -nologo + $result | Should -Be $true + } + } + + Context 'Properties and Configuration' { + It 'Respects Configuration parameter' { + $params = @{ Configuration = 'Release' } + $result = Invoke-psake -buildFile $BuildFile -parameters $params -taskList ShowConfig -nologo + $result | Should -Be $true + } + + It 'Respects Environment parameter' { + $params = @{ Environment = 'staging' } + $result = Invoke-psake -buildFile $BuildFile -parameters $params -taskList ShowConfig -nologo + $result | Should -Be $true + } + } + + Context 'Error Handling' { + It 'Fails gracefully on invalid task' { + $result = Invoke-psake -buildFile $BuildFile -taskList InvalidTask -nologo + $result | Should -Be $false + } + + It 'Validates required environment variables' { + $originalEnv = $env:REQUIRED_VAR + $env:REQUIRED_VAR = $null + + try { + { Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo } | Should -Throw + } + 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..b75f71d --- /dev/null +++ b/docs/best-practices/versioning-strategy.md @@ -0,0 +1,728 @@ +--- +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 +Properties { + # Base version from git tag + $BaseVersion = Get-GitVersion + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + + # Construct full version + $Version = "$BaseVersion.$BuildNumber" +} + +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' +} + +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 + +```powershell +function Get-GitVersion { + <# + .SYNOPSIS + Gets version from git tags + #> + + try { + # Get the latest tag + $latestTag = git describe --tags --abbrev=0 2>$null + + if ([string]::IsNullOrEmpty($latestTag)) { + Write-Warning "No git tags found, using default version" + return '0.1.0' + } + + # Parse version from tag (handles v1.0.0 or 1.0.0) + if ($latestTag -match '^v?(\d+)\.(\d+)\.(\d+)') { + $major = [int]$matches[1] + $minor = [int]$matches[2] + $patch = [int]$matches[3] + + # Get commits since tag + $commitsSinceTag = git rev-list "$latestTag..HEAD" --count 2>$null + + if ($commitsSinceTag -gt 0) { + # Bump patch for commits since last tag + $patch++ + return "$major.$minor.$patch-dev.$commitsSinceTag" + } + + return "$major.$minor.$patch" + } + + Write-Warning "Tag format not recognized: $latestTag" + return '0.1.0' + } + catch { + Write-Warning "Error getting git version: $_" + return '0.1.0' + } +} + +Properties { + $GitVersion = Get-GitVersion + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + $Version = $GitVersion +} + +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: + tag: '' + develop: + tag: 'beta' + feature: + tag: 'alpha.{BranchName}' + release: + tag: 'rc' + hotfix: + tag: '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' } + + # Construct version + $Version = "$BaseVersion.$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_BUILDNUMBER) { $env:BUILD_BUILDNUMBER } else { '0' } + $Version = "$BaseVersion.$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 + ) + + if (-not (Test-Path $ProjectFile)) { + throw "Project file not found: $ProjectFile" + } + + Write-Host "Updating version in $ProjectFile to $Version" -ForegroundColor Green + + # Load project file + [xml]$project = Get-Content $ProjectFile + + # Find or create PropertyGroup + $propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1 + + if ($null -eq $propertyGroup) { + $propertyGroup = $project.CreateElement('PropertyGroup') + $project.Project.AppendChild($propertyGroup) | Out-Null + } + + # Update or create version properties + $versionProperties = @( + 'Version', + 'AssemblyVersion', + 'FileVersion' + ) + + foreach ($propName in $versionProperties) { + if ($null -eq $propertyGroup.$propName) { + $propNode = $project.CreateElement($propName) + $propertyGroup.AppendChild($propNode) | Out-Null + } + $propertyGroup.$propName = $Version + } + + # Save updated project file + $project.Save($ProjectFile) + + Write-Host " Version updated to: $Version" -ForegroundColor Gray +} + +Task UpdateProjectVersions { + Write-Host "Updating project versions..." -ForegroundColor Green + + $projects = Get-ChildItem "$SrcDir/**/*.csproj" -Recurse + + foreach ($project in $projects) { + Update-ProjectVersion -ProjectFile $project.FullName -Version $Version + } + + 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 + ) + + if (-not (Test-Path $AssemblyInfoPath)) { + throw "AssemblyInfo.cs not found: $AssemblyInfoPath" + } + + Write-Host "Updating AssemblyInfo: $AssemblyInfoPath" -ForegroundColor Green + + $content = Get-Content $AssemblyInfoPath + + # Update version attributes + $content = $content -replace '\[assembly: AssemblyVersion\(".*?"\)\]', "[assembly: AssemblyVersion(""$Version"")]" + $content = $content -replace '\[assembly: AssemblyFileVersion\(".*?"\)\]', "[assembly: AssemblyFileVersion(""$Version"")]" + $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 + } +} +``` + +### 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$' { + # Production release + return "$baseVersion.$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 "$SrcDir/**/*.csproj" -Recurse + + foreach ($project in $projects) { + [xml]$proj = Get-Content $project.FullName + + $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 43ccde1..8a73465 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -52,6 +52,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 Examples',