From 2520050d319c90293ea61f9c2fa0528a964f0d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 03:53:31 +0000 Subject: [PATCH 1/3] 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 7d918d7..5de17c9 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -60,6 +60,17 @@ const sidebars: SidebarsConfig = { 'build-types/docker' ] }, + { + type: 'category', + label: 'Best Practices', + items: [ + 'best-practices/organizing-large-scripts', + 'best-practices/environment-management', + 'best-practices/secret-management', + 'best-practices/testing-build-scripts', + 'best-practices/versioning-strategy' + ] + }, { type: 'category', label: 'CI/CD Integration', From 8e48413c6e063eff973264c3303d82fac7273597 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Sun, 30 Aug 2026 20:42:07 +0000 Subject: [PATCH 2/3] Fix best practices documentation examples --- docs/best-practices/environment-management.md | 354 ++++++++++------- .../organizing-large-scripts.md | 363 ++++++++---------- docs/best-practices/secret-management.md | 224 +++++------ docs/best-practices/testing-build-scripts.md | 272 ++++++------- docs/best-practices/versioning-strategy.md | 148 +++---- 5 files changed, 647 insertions(+), 714 deletions(-) diff --git a/docs/best-practices/environment-management.md b/docs/best-practices/environment-management.md index a411242..8417863 100644 --- a/docs/best-practices/environment-management.md +++ b/docs/best-practices/environment-management.md @@ -57,41 +57,40 @@ 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' } + $environmentSettings = @{ + dev = @{ + Configuration = 'Debug' + DatabaseServer = 'localhost' + ApiUrl = 'http://localhost:5000' + EnableTelemetry = $false + LogLevel = 'Debug' + } + staging = @{ + Configuration = 'Release' + DatabaseServer = 'db-staging.internal' + ApiUrl = 'https://api-staging.example.com' + EnableTelemetry = $true + LogLevel = 'Information' + } + prod = @{ + Configuration = 'Release' + DatabaseServer = 'db-prod.internal' + ApiUrl = 'https://api.example.com' + EnableTelemetry = $true + LogLevel = 'Warning' + } } - # Database connection strings - $DatabaseServer = switch ($Environment) { - 'dev' { 'localhost' } - 'staging' { 'db-staging.internal' } - 'prod' { 'db-prod.internal' } + if (-not $environmentSettings.ContainsKey($Environment)) { + throw "Unknown environment: $Environment" } - # 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' } - } + $settings = $environmentSettings[$Environment] + $Configuration = $settings.Configuration + $DatabaseServer = $settings.DatabaseServer + $ApiUrl = $settings.ApiUrl + $EnableTelemetry = $settings.EnableTelemetry + $LogLevel = $settings.LogLevel } Task Build { @@ -242,22 +241,24 @@ Properties { **psakefile.ps1:** ```powershell +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$configDirectory = Join-Path $PSScriptRoot 'build/config' +$environmentConfig = Join-Path $configDirectory "${selectedEnvironment}.ps1" + +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig. Valid environments: dev, staging, prod" +} + +Write-Host "Loading configuration for: $selectedEnvironment" -ForegroundColor Cyan +Include $environmentConfig + Properties { $ProjectRoot = $PSScriptRoot + $BuildDir = Join-Path $ProjectRoot 'build/output' $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } $ConfigDir = Join-Path $ProjectRoot 'build/config' } -# 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 { @@ -269,19 +270,28 @@ Task Build { exec { dotnet build -c $Configuration } } +function Deploy-AzureWebApp { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile.FullName ` + --type zip + } +} + Task Deploy -depends Build { - if ($RequireApproval) { - $confirmation = Read-Host "Deploy to $Environment? This is a PRODUCTION environment! (yes/no)" - if ($confirmation -ne 'yes') { - Write-Host "Deployment cancelled" -ForegroundColor Yellow - return - } + if ($RequireApproval -and $env:DEPLOY_APPROVED -ne 'true') { + throw "Deployment to $Environment requires DEPLOY_APPROVED=true" } switch ($DeploymentTarget) { - 'local' { Invoke-psake -taskList Deploy:Local } - 'azure-staging' { Invoke-psake -taskList Deploy:Azure } - 'azure-production' { Invoke-psake -taskList Deploy:Azure } + 'local' { Copy-Item "$BuildDir/*" -Destination "C:\Deploy\$Environment" -Recurse -Force } + 'azure-staging' { Deploy-AzureWebApp } + 'azure-production' { Deploy-AzureWebApp } default { throw "Unknown deployment target: $DeploymentTarget" } } } @@ -346,37 +356,60 @@ Use structured configuration files for complex settings: } ``` +**build/config/environments.yaml:** + +```yaml +dev: + configuration: Debug + services: + api: http://localhost:5000 +staging: + configuration: Release + services: + api: https://api-staging.example.com +prod: + configuration: Release + services: + api: https://api.example.com +``` + +For YAML, install and import `powershell-yaml`, then replace the JSON-loading expression with: + +```powershell +Import-Module powershell-yaml +$configurationFile = Join-Path $PSScriptRoot 'build/config/environments.yaml' +$allConfigs = Get-Content $configurationFile -Raw | ConvertFrom-Yaml +``` + **psakefile.ps1:** ```powershell -Properties { - $ProjectRoot = $PSScriptRoot - $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } - $ConfigFile = Join-Path $ProjectRoot 'build/config/environments.json' -} +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$configurationFile = Join-Path $PSScriptRoot 'build/config/environments.json' -# Load and parse configuration -if (-not (Test-Path $ConfigFile)) { - throw "Configuration file not found: $ConfigFile" +if (-not (Test-Path $configurationFile)) { + throw "Configuration file not found: $configurationFile" } -$allConfigs = Get-Content $ConfigFile | ConvertFrom-Json -$config = $allConfigs.$Environment +$allConfigs = Get-Content $configurationFile -Raw | ConvertFrom-Json +$selectedConfig = $allConfigs.$selectedEnvironment -if ($null -eq $config) { - throw "Configuration for environment '$Environment' not found in $ConfigFile" +if ($null -eq $selectedConfig) { + throw "Configuration for environment '$selectedEnvironment' not found in $configurationFile" } -# 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 + $ProjectRoot = $PSScriptRoot + $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + $ConfigFile = Join-Path $ProjectRoot 'build/config/environments.json' + $Configuration = $selectedConfig.configuration + $DatabaseServer = $selectedConfig.database.server + $DatabaseName = $selectedConfig.database.name + $DatabasePort = $selectedConfig.database.port + $ApiUrl = $selectedConfig.services.api + $AuthUrl = $selectedConfig.services.auth + $EnableCaching = $selectedConfig.features.caching + $EnableTelemetry = $selectedConfig.features.telemetry } Task Build { @@ -436,8 +469,8 @@ Task DeployToProduction -depends Build -precondition { $Environment -eq 'prod' } exec { dotnet test --filter Category=Smoke } } -Task SkipTestsInDev -precondition { $Environment -ne 'dev' } { - Invoke-psake -taskList RunTests +Task TestOutsideDevelopment -precondition { $Environment -ne 'dev' } { + exec { dotnet test } } ``` @@ -448,7 +481,6 @@ Properties { $Environment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } } -Task Default -depends Build Task Build -depends Clean, Compile @@ -464,15 +496,17 @@ Task Production -depends Build, RunAllTests, SecurityScan, Package, DeployProduc 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" } - } +$environmentTasks = @{ + dev = 'Dev' + staging = 'Staging' + prod = 'Production' +} +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +if (-not $environmentTasks.ContainsKey($selectedEnvironment)) { + throw "Unknown environment: $selectedEnvironment" } + +Task Default -depends $environmentTasks[$selectedEnvironment] ``` ### Conditional Build Steps @@ -502,8 +536,9 @@ Task Build { } if ($EnableTelemetry) { - Write-Host "Instrumenting for telemetry..." -ForegroundColor Cyan - # Add telemetry instrumentation + $env:OTEL_SERVICE_NAME = 'MyApp' + $env:OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=$Environment" + Write-Host "Configured OpenTelemetry resource attributes" -ForegroundColor Cyan } } ``` @@ -515,30 +550,27 @@ Here's a comprehensive example combining all patterns: **psakefile.ps1:** ```powershell +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } +$validEnvironments = @('dev', 'staging', 'prod') +if ($selectedEnvironment -notin $validEnvironments) { + throw "Invalid environment: $selectedEnvironment. Valid options: $($validEnvironments -join ', ')" +} + +$configDirectory = Join-Path $PSScriptRoot 'build/config' +$environmentConfig = Join-Path $configDirectory "${selectedEnvironment}.ps1" +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig" +} + +Write-Host "Loading environment configuration: $selectedEnvironment" -ForegroundColor Cyan +Include $environmentConfig + Properties { - # 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 { @@ -610,15 +642,19 @@ Task Package -depends Test { 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)" +function Test-DeploymentHealth { + if ($SkipHealthChecks) { return } - if ($confirmation -ne 'yes') { - Write-Host "Deployment cancelled" -ForegroundColor Yellow - return - } + $response = Invoke-WebRequest -Uri "$ApiBaseUrl/health" -TimeoutSec 30 + if ($response.StatusCode -ne 200) { + throw "Health check failed with status: $($response.StatusCode)" + } + Write-Host " Health check passed" -ForegroundColor Green +} + +Task Deploy -depends Package { + if ($RequireApproval -and $env:DEPLOY_APPROVED -ne 'true') { + throw "Deployment to $Environment requires DEPLOY_APPROVED=true" } Write-Host "Deploying to $DeploymentTarget..." -ForegroundColor Green @@ -627,29 +663,24 @@ Task Deploy -depends Package { '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' { + { $_ -in 'azure-staging', 'azure-production' } { + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } + exec { - az webapp deployment source config-zip ` + az webapp deploy ` --resource-group $AzureResourceGroup ` --name $AzureWebAppName ` - --src "$BuildDir/*.zip" + --src-path $packageFile.FullName ` + --type zip } - # Run health checks after production deployment - Start-Sleep -Seconds 10 - Invoke-psake -taskList HealthCheck - } - default { - throw "Unknown deployment target: $DeploymentTarget" + if ($DeploymentTarget -eq 'azure-production') { + Start-Sleep -Seconds 10 + Test-DeploymentHealth + } } + default { throw "Unknown deployment target: $DeploymentTarget" } } Write-Host "Deployment to $Environment complete!" -ForegroundColor Green @@ -731,13 +762,17 @@ jobs: shell: pwsh run: Install-Module -Name psake -Force + - name: Sign in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS_STAGING }} + - name: Build and Deploy to Staging shell: pwsh run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy env: BUILD_ENV: staging - AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS_STAGING }} - + DEPLOY_APPROVED: 'true' build-production: runs-on: windows-latest if: github.ref == 'refs/heads/main' @@ -749,12 +784,69 @@ jobs: shell: pwsh run: Install-Module -Name psake -Force + - name: Sign in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS_PROD }} + - name: Build and Deploy to Production shell: pwsh run: Invoke-psake -buildFile .\psakefile.ps1 -taskList Deploy env: BUILD_ENV: prod - AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS_PROD }} + DEPLOY_APPROVED: 'true' +``` + +### Azure Pipelines + +Use an Azure Resource Manager service connection so credentials never enter the build script: + +```yaml +trigger: + branches: + include: + - main + - develop + +stages: + - stage: Build + jobs: + - job: Build + pool: + vmImage: windows-latest + steps: + - checkout: self + - pwsh: Install-Module -Name psake -Force + displayName: Install psake + - pwsh: Invoke-psake -BuildFile .\psakefile.ps1 -TaskList Build + displayName: Build + env: + BUILD_ENV: dev + + - stage: DeployProduction + dependsOn: Build + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: Deploy + environment: production + pool: + vmImage: windows-latest + strategy: + runOnce: + deploy: + steps: + - checkout: self + - task: AzureCLI@2 + inputs: + azureSubscription: MyProductionServiceConnection + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + Install-Module -Name psake -Force + Invoke-psake -BuildFile .\psakefile.ps1 -TaskList Deploy + env: + BUILD_ENV: prod + DEPLOY_APPROVED: 'true' ``` ## Best Practices @@ -763,7 +855,7 @@ jobs: 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 +5. **Require approval for production** - Use protected CI environments and a noninteractive approval signal 6. **Use preconditions** - Leverage psake preconditions for environment-specific tasks 7. **Keep secrets separate** - Never put secrets in environment config files (see [Secret Management](/docs/best-practices/secret-management)) 8. **Test all environments** - Validate builds for all environments in CI/CD diff --git a/docs/best-practices/organizing-large-scripts.md b/docs/best-practices/organizing-large-scripts.md index 49fccc2..b60566b 100644 --- a/docs/best-practices/organizing-large-scripts.md +++ b/docs/best-practices/organizing-large-scripts.md @@ -29,13 +29,13 @@ 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') +# Include paths must be resolved while the build file is loaded. +$tasksDirectory = Join-Path $PSScriptRoot 'build/tasks' +Include (Join-Path $tasksDirectory 'build.ps1') +Include (Join-Path $tasksDirectory 'test.ps1') +Include (Join-Path $tasksDirectory 'deploy.ps1') Task Default -depends Build, Test ``` @@ -70,32 +70,30 @@ my-project/ **psakefile.ps1:** ```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' +$utilsDirectory = Join-Path $buildRoot 'utils' +$tasksDirectory = Join-Path $buildRoot 'tasks' +$configDirectory = Join-Path $buildRoot 'config' +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + +# Includes are resolved while the build file is loaded. +Include (Join-Path $utilsDirectory 'logging.ps1') +Include (Join-Path $utilsDirectory 'fileops.ps1') +Include (Join-Path $utilsDirectory 'versioning.ps1') +Include (Join-Path $configDirectory "${selectedEnvironment}.ps1") +Include (Join-Path $tasksDirectory 'compile.ps1') +Include (Join-Path $tasksDirectory 'test.ps1') +Include (Join-Path $tasksDirectory 'package.ps1') +Include (Join-Path $tasksDirectory 'deploy.ps1') +Include (Join-Path $tasksDirectory 'cleanup.ps1') + Properties { $ProjectRoot = $PSScriptRoot $BuildRoot = Join-Path $ProjectRoot 'build' - $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" @@ -137,46 +135,40 @@ my-project/ **psakefile.ps1:** ```powershell -Properties { - $ProjectRoot = $PSScriptRoot - $BuildRoot = Join-Path $ProjectRoot 'build' - $BuildType = 'all' # Options: library, service, tools, all -} +$buildRoot = Join-Path $PSScriptRoot 'build' +$buildType = if ($env:BUILD_TYPE) { $env:BUILD_TYPE } else { 'all' } +$buildTasks = @() -# Load shared utilities -Include (Join-Path $BuildRoot 'shared/common.ps1') +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 -in 'library', 'all') { + Include (Join-Path $buildRoot 'tasks/library/build.ps1') + Include (Join-Path $buildRoot 'tasks/library/test.ps1') + Include (Join-Path $buildRoot 'tasks/library/publish.ps1') + $buildTasks += 'Library:Build' } -if ($BuildType -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 -in 'service', 'all') { + Include (Join-Path $buildRoot 'tasks/service/build.ps1') + Include (Join-Path $buildRoot 'tasks/service/docker.ps1') + Include (Join-Path $buildRoot 'tasks/service/deploy.ps1') + $buildTasks += 'Service:Build' } -if ($BuildType -eq 'tools' -or $BuildType -eq 'all') { - Include (Join-Path $BuildRoot 'tasks/tools/build.ps1') - Include (Join-Path $BuildRoot 'tasks/tools/package.ps1') +if ($buildType -in 'tools', 'all') { + Include (Join-Path $buildRoot 'tasks/tools/build.ps1') + Include (Join-Path $buildRoot 'tasks/tools/package.ps1') + $buildTasks += 'Tools:Build' } -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 - } +Properties { + $ProjectRoot = $PSScriptRoot + $BuildRoot = Join-Path $ProjectRoot 'build' + $BuildType = if ($env:BUILD_TYPE) { $env:BUILD_TYPE } else { 'all' } } + +Task Default -depends Build +Task Build -depends $buildTasks ``` ## Modular Task Files @@ -194,34 +186,32 @@ Properties { $BuildDir = Join-Path $ProjectRoot 'build/output' } -Task Compile -depends Clean { - Write-Host "Compiling solution..." -ForegroundColor Green +function Invoke-SolutionBuild { + param([string]$BuildConfiguration) $solutionFile = Get-ChildItem "$SrcDir/*.sln" | Select-Object -First 1 - - if (-not $solutionFile) { - throw "No solution file found in $SrcDir" - } + if (-not $solutionFile) { throw "No solution file found in $SrcDir" } exec { dotnet build $solutionFile.FullName ` - -c $Configuration ` + -c $BuildConfiguration ` -o $BuildDir ` /p:Version=$Version ` --no-incremental } +} +Task Compile -depends Clean { + Invoke-SolutionBuild -BuildConfiguration $Configuration Write-Host "Compilation complete: $BuildDir" -ForegroundColor Green } -Task CompileDebug { - $script:Configuration = 'Debug' - Invoke-psake -taskList Compile +Task CompileDebug -depends Clean { + Invoke-SolutionBuild -BuildConfiguration Debug } -Task CompileRelease { - $script:Configuration = 'Release' - Invoke-psake -taskList Compile +Task CompileRelease -depends Clean { + Invoke-SolutionBuild -BuildConfiguration Release } Task Restore { @@ -323,49 +313,50 @@ Task TestIntegration -depends Build { **build/tasks/deploy.ps1:** ```powershell +$deployTaskByTarget = @{ + azure = 'Deploy:Azure' + aws = 'Deploy:AWS' + local = 'Deploy:Local' +} +$selectedDeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'local' } +if (-not $deployTaskByTarget.ContainsKey($selectedDeployTarget)) { + throw "Unknown deployment target: $selectedDeployTarget" +} + Properties { - $DeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'dev' } + $DeployTarget = if ($env:DEPLOY_TARGET) { $env:DEPLOY_TARGET } else { 'local' } $DeploymentDir = Join-Path $ProjectRoot 'deployment' + $AwsDeploymentBucket = $env:AWS_DEPLOYMENT_BUCKET + $AwsApplicationName = $env:AWS_APPLICATION_NAME + $AwsDeploymentGroup = $env:AWS_DEPLOYMENT_GROUP } -Task Deploy -depends 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 -depends $deployTaskByTarget[$selectedDeployTarget] 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 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } exec { - az webapp deployment source config-zip ` - --resource-group $resourceGroup ` - --name $webAppName ` - --src $packageFile.FullName + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile.FullName ` + --type zip } - - Write-Host "Deployed to Azure: https://${webAppName}.azurewebsites.net" -ForegroundColor Green } Task Deploy:AWS { - Write-Host "Deploying to AWS..." -ForegroundColor Green + $packageFile = Get-ChildItem "$BuildDir/*.zip" | Select-Object -First 1 + if (-not $packageFile) { throw "Deployment package not found in $BuildDir" } - # AWS deployment logic here - throw "AWS deployment not yet implemented" + exec { aws s3 cp $packageFile.FullName "s3://$AwsDeploymentBucket/$($packageFile.Name)" } + exec { + aws deploy create-deployment ` + --application-name $AwsApplicationName ` + --deployment-group-name $AwsDeploymentGroup ` + --s3-location "bucket=$AwsDeploymentBucket,key=$($packageFile.Name),bundleType=zip" + } } Task Deploy:Local { @@ -390,24 +381,18 @@ The `Include` function allows you to split build logic across multiple files. ### Include with Path Validation ```powershell -Properties { - $BuildRoot = Join-Path $PSScriptRoot 'build' -} +$buildRoot = Join-Path $PSScriptRoot 'build' -# Helper function to safely include files function Include-TaskFile { param([string]$RelativePath) - $fullPath = Join-Path $BuildRoot $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' @@ -416,26 +401,20 @@ Include-TaskFile 'tasks/deploy.ps1' ### Dynamic Includes Based on Configuration ```powershell -Properties { - $ProjectType = 'dotnet' # Options: dotnet, nodejs, docker - $TasksDir = Join-Path $PSScriptRoot 'build/tasks' -} +$projectType = if ($env:PROJECT_TYPE) { $env:PROJECT_TYPE } else { 'dotnet' } +$tasksDirectory = Join-Path $PSScriptRoot 'build/tasks' -# Include common tasks -Include (Join-Path $TasksDir 'common.ps1') +Include (Join-Path $tasksDirectory '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" +$projectTaskFile = Join-Path $tasksDirectory "${projectType}.ps1" +if (-not (Test-Path $projectTaskFile)) { + throw "No task file found for project type: $projectType" } +Include $projectTaskFile -# Include optional tasks if they exist $optionalTasks = @('docker.ps1', 'kubernetes.ps1', 'terraform.ps1') foreach ($taskFile in $optionalTasks) { - $fullPath = Join-Path $TasksDir $taskFile + $fullPath = Join-Path $tasksDirectory $taskFile if (Test-Path $fullPath) { Write-Host "Loading optional tasks: $taskFile" -ForegroundColor Gray Include $fullPath @@ -549,13 +528,6 @@ function Get-FileHash256 { 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 @@ -615,16 +587,6 @@ function Write-LogStep { 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 @@ -634,63 +596,22 @@ Export-ModuleMember -Function @( ```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]$BaseVersion = '1.0.0', [string]$BuildNumber = $null ) if ([string]::IsNullOrEmpty($BuildNumber)) { - $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { "0" } + $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" + if ($BaseVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "Base version is not valid SemVer: $BaseVersion" } - return "$BaseVersion.$BuildNumber" + return "$BaseVersion-ci.$BuildNumber" } function Set-AssemblyVersion { @@ -719,11 +640,6 @@ function Set-AssemblyVersion { Write-Host "Updated version to $Version in $ProjectFile" -ForegroundColor Green } -Export-ModuleMember -Function @( - 'Get-GitVersion', - 'Get-BuildVersion', - 'Set-AssemblyVersion' -) ``` ## Complete Example: Large Project @@ -733,41 +649,37 @@ Here's a complete example combining all patterns: **psakefile.ps1:** ```powershell +$buildRoot = Join-Path $PSScriptRoot 'build' +$selectedEnvironment = if ($env:BUILD_ENV) { $env:BUILD_ENV } else { 'dev' } + +Include (Join-Path $buildRoot 'utils/logging.ps1') +Include (Join-Path $buildRoot 'utils/fileops.ps1') +Include (Join-Path $buildRoot 'utils/versioning.ps1') + +$environmentConfig = Join-Path $buildRoot "config/${selectedEnvironment}.ps1" +if (-not (Test-Path $environmentConfig)) { + throw "Environment configuration not found: $environmentConfig" +} +Include $environmentConfig + +Include (Join-Path $buildRoot 'tasks/compile.ps1') +Include (Join-Path $buildRoot 'tasks/test.ps1') +Include (Join-Path $buildRoot 'tasks/package.ps1') +Include (Join-Path $buildRoot 'tasks/deploy.ps1') +Include (Join-Path $buildRoot 'tasks/cleanup.ps1') + Properties { - # 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' } + $Version = Get-BuildVersion -BaseVersion '1.0.0' -BuildNumber $BuildNumber } -# 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) @@ -794,6 +706,35 @@ Task Full -depends Clean, Restore, Compile, TestWithCoverage, Package, Deploy { } ``` +The environment and utility files are the complete implementations shown earlier in this guide. Add these remaining task files: + +**build/tasks/package.ps1:** + +```powershell +Task Package -depends Build { + if (-not (Test-Path $BuildDir)) { + throw "Build output not found: $BuildDir" + } + + $packagePath = Join-Path $BuildRoot "MyApp-$Version.zip" + Compress-Archive -Path "$BuildDir/*" -DestinationPath $packagePath -Force + Write-LogSuccess "Created $packagePath" +} +``` + +**build/tasks/cleanup.ps1:** + +```powershell +Task Clean { + if (Test-Path $BuildDir) { + Remove-Item $BuildDir -Recurse -Force + } + New-Item -ItemType Directory -Path $BuildDir -Force | Out-Null +} +``` + +Use the `compile.ps1`, `test.ps1`, and `deploy.ps1` implementations from the modular task-file examples above. Together, these files satisfy every `Include` in the orchestrator. + ## Best Practices Summary 1. **Use a clear directory structure** - Organize by category or build type diff --git a/docs/best-practices/secret-management.md b/docs/best-practices/secret-management.md index 4d55a75..5edc453 100644 --- a/docs/best-practices/secret-management.md +++ b/docs/best-practices/secret-management.md @@ -148,16 +148,20 @@ Task LoadCredentials { Task Deploy -depends Build, LoadCredentials { Write-Host "Deploying with saved credentials..." -ForegroundColor Green - $username = $DeploymentCredential.UserName - $password = $DeploymentCredential.GetNetworkCredential().Password + $arguments = @( + '-verb:sync' + "-source:package=$BuildDir\package.zip" + '-dest:auto,computerName=https://server.example.com:8172/msdeploy.axd' + '-allowUntrusted' + ) + $process = Start-Process msdeploy ` + -ArgumentList $arguments ` + -Credential $DeploymentCredential ` + -Wait -PassThru -NoNewWindow - # 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" + if ($process.ExitCode -ne 0) { + throw "Deployment failed with exit code $($process.ExitCode)" + } } ``` @@ -169,27 +173,16 @@ For Azure-hosted secrets: 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 } + if ($env:CI) { + # CI authenticates before psake with azure/login or an equivalent workload identity. + exec { az account show --output none } } else { - # Service principal login for CI/CD - exec { - az login --service-principal ` - --tenant $AzureTenantId ` - --username $AzureClientId ` - --password $AzureClientSecret - } + exec { az login } } Write-Host "Azure authentication successful" -ForegroundColor Green @@ -226,23 +219,28 @@ Task GetSecretsFromKeyVault -depends AzureLogin { 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 +Task Deploy -depends Build, AzureLogin { + $databaseSecretUri = az keyvault secret show ` + --name DatabasePassword --vault-name $KeyVaultName --query id -o tsv + $apiKeySecretUri = az keyvault secret show ` + --name ApiKey --vault-name $KeyVaultName --query id -o tsv + $packageFile = Join-Path $BuildDir 'package.zip' - # Deploy application - exec { az webapp deployment source config-zip --src "$BuildDir/package.zip" } + exec { + az webapp config appsettings set ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --settings ` + "ConnectionStrings__DefaultConnection=@Microsoft.KeyVault(SecretUri=$databaseSecretUri)" ` + "ExternalServices__ApiKey=@Microsoft.KeyVault(SecretUri=$apiKeySecretUri)" + } + exec { + az webapp deploy ` + --resource-group $AzureResourceGroup ` + --name $AzureWebAppName ` + --src-path $packageFile ` + --type zip + } } ``` @@ -297,41 +295,37 @@ Task GetSecretsFromAWS -depends VerifyAwsCli { Write-Host " Retrieved secrets successfully" -ForegroundColor Green } -Task CreateAwsSecret { +Task SetAwsSecret { param( [string]$SecretName = 'myapp/prod', - [string]$SecretValue + [SecureString]$SecretValue ) - Write-Host "Creating secret in AWS Secrets Manager..." -ForegroundColor Green - - if ([string]::IsNullOrEmpty($SecretValue)) { - # Interactive input + if ($null -eq $SecretValue) { $SecretValue = Read-Host "Enter secret value" -AsSecureString - $SecretValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( - [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecretValue) - ) } - # Create or update secret + Import-Module AWS.Tools.SecretsManager + $credential = [PSCredential]::new('secret', $SecretValue) + $plainText = $credential.GetNetworkCredential().Password + try { - exec { - aws secretsmanager create-secret ` - --name $SecretName ` - --secret-string $SecretValue ` - --region $AwsRegion + $existingSecret = Get-SECSecretList | + Where-Object Name -eq $SecretName | + Select-Object -First 1 + + if ($existingSecret) { + Update-SECSecret -SecretId $SecretName -SecretString $plainText + Write-Host "Secret updated: $SecretName" -ForegroundColor Green + } else { + New-SECSecret -Name $SecretName -SecretString $plainText + Write-Host "Secret created: $SecretName" -ForegroundColor Green } - 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 + finally { + $plainText = $null + $credential = $null + $SecretValue.Dispose() } } @@ -493,29 +487,22 @@ Task ValidateSecrets { ```powershell Properties { - $CertificatePath = Join-Path $ProjectRoot 'certs/signing.pfx' - $CertificatePassword = $env:SIGNING_CERT_PASSWORD + $CertificateThumbprint = $env:SIGNING_CERT_THUMBPRINT } Task SignAssemblies -depends Build { Write-Host "Signing assemblies..." -ForegroundColor Green - if ([string]::IsNullOrEmpty($CertificatePassword)) { - throw "SIGNING_CERT_PASSWORD environment variable is required" + if ([string]::IsNullOrEmpty($CertificateThumbprint)) { + throw "SIGNING_CERT_THUMBPRINT 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 ` + signtool sign /sha1 $CertificateThumbprint ` + /tr http://timestamp.digicert.com ` + /td SHA256 ` /fd SHA256 ` $assembly.FullName } -errorMessage "Failed to sign $($assembly.Name)" @@ -525,30 +512,28 @@ Task SignAssemblies -depends Build { } ``` -### Cleanup Secrets After Use +### Minimize Secret Lifetime + +Strings cannot be reliably erased from managed memory. Prefer APIs that accept `SecureString`, scope secrets narrowly, and dispose sensitive objects deterministically: ```powershell -Task Deploy { - try { - # Retrieve secrets - $apiKey = $env:API_KEY - $dbPassword = $env:DB_PASSWORD +Task InstallSigningCertificate { + $password = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $certificate = $null - # Use secrets - exec { dotnet publish --api-key $apiKey } + try { + $certificate = Import-PfxCertificate ` + -FilePath $CertificatePath ` + -CertStoreLocation Cert:\CurrentUser\My ` + -Password $password - # Create temporary connection string - $connectionString = "Server=db;Database=MyApp;Password=$dbPassword;" - # Use connection string... + exec { signtool sign /sha1 $certificate.Thumbprint /fd SHA256 $PackagePath } } finally { - # Clear sensitive variables - $apiKey = $null - $dbPassword = $null - $connectionString = $null - - # Force garbage collection - [System.GC]::Collect() + if ($certificate) { + Remove-Item $certificate.PSPath -Force + } + $password.Dispose() } } ``` @@ -654,26 +639,16 @@ Task ValidateSecrets { Write-Host " Secrets validation passed" -ForegroundColor Green } -Task GetSecrets -depends ValidateSecrets { - if ($UseKeyVault) { - Invoke-psake -taskList GetSecretsFromKeyVault - } else { +Task GetSecrets -depends ValidateSecrets, GetSecretsFromKeyVault { + if (-not $UseKeyVault) { Write-Host "Using secrets from environment variables" -ForegroundColor Gray } } -Task GetSecretsFromKeyVault { +Task GetSecretsFromKeyVault -precondition { $UseKeyVault } { 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 + # CI authenticates before psake with azure/login or an equivalent workload identity. $script:NuGetApiKey = az keyvault secret show ` --name "NuGetApiKey" ` --vault-name $AzureKeyVaultName ` @@ -695,22 +670,21 @@ Task Pack -depends 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)" + try { + $packages = Get-ChildItem "$BuildDir/*.nupkg" + foreach ($package in $packages) { + Write-Host " Publishing: $($package.Name)" -ForegroundColor Gray + exec { + dotnet nuget push $package.FullName ` + --api-key $NuGetApiKey ` + --source https://api.nuget.org/v3/index.json + } -errorMessage "Failed to publish package (credentials redacted)" + } + Write-Host "Publishing complete" -ForegroundColor Green + } + finally { + $script:NuGetApiKey = $null } - - # Clear sensitive data - $script:NuGetApiKey = $null - - Write-Host "Publishing complete" -ForegroundColor Green } ``` diff --git a/docs/best-practices/testing-build-scripts.md b/docs/best-practices/testing-build-scripts.md index 564bc83..b1a8daf 100644 --- a/docs/best-practices/testing-build-scripts.md +++ b/docs/best-practices/testing-build-scripts.md @@ -27,13 +27,13 @@ Describe 'psake Build Script' { Test-Path $BuildFile | Should -Be $true } - It 'Build file is valid PowerShell' { - { . $BuildFile } | Should -Not -Throw + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw } It 'Default task executes successfully' { $result = Invoke-psake -buildFile $BuildFile -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } } ``` @@ -79,8 +79,8 @@ Describe 'psake Build Configuration' { Test-Path $BuildFile | Should -Be $true } - It 'Build file has no syntax errors' { - { $null = & $BuildFile } | Should -Not -Throw + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw } } @@ -101,7 +101,7 @@ Describe 'Build Tasks' { Context 'Build Task' { It 'Executes without errors' { $result = Invoke-psake -buildFile $BuildFile -taskList Build -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } It 'Creates build artifacts' { @@ -129,51 +129,27 @@ Ensure tasks execute in the correct order: 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 - } + $script:Tasks = Get-PSakeScriptTasks -BuildFile $BuildFile } It 'Build depends on Compile' { - # Load build file - . $BuildFile - - # Get task dependencies - $buildTask = Get-PSakeScriptTask -taskName 'Build' + $buildTask = $Tasks | Where-Object Name -eq 'Build' $buildTask.DependsOn | Should -Contain 'Compile' } It 'Deploy depends on Build and Test' { - . $BuildFile - - $deployTask = Get-PSakeScriptTask -taskName 'Deploy' + $deployTask = $Tasks | Where-Object Name -eq '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 + It 'Executes dependencies before Deploy' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList Deploy -NoLogo + $result.Success | Should -BeTrue - # Verify order - $executionOrder.IndexOf('Compile') | Should -BeLessThan $executionOrder.IndexOf('Build') - $executionOrder.IndexOf('Build') | Should -BeLessThan $executionOrder.IndexOf('Deploy') + $executedTasks = @($result.Tasks | Where-Object Status -eq 'Executed' | ForEach-Object Name) + $executedTasks.IndexOf('Compile') | Should -BeLessThan $executedTasks.IndexOf('Build') + $executedTasks.IndexOf('Build') | Should -BeLessThan $executedTasks.IndexOf('Deploy') } } ``` @@ -191,26 +167,12 @@ Describe 'Build with Mocked dotnet' { # 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 - } + switch ($args[0]) { + 'build' { 'Build succeeded' } + 'test' { 'Tests passed: 50 passed, 0 failed' } + 'publish' { 'Publish succeeded' } } - } -ModuleName psake + } } It 'Compile task calls dotnet build' { @@ -218,16 +180,16 @@ Describe 'Build with Mocked dotnet' { # Verify dotnet build was called Should -Invoke -CommandName 'dotnet' -ParameterFilter { - $Command -eq 'build' - } -Times 1 -ModuleName psake + $args[0] -eq 'build' + } -Times 1 } 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 + $args[0] -eq 'test' + } -Times 1 } } ``` @@ -274,25 +236,13 @@ Describe 'Azure Deployment' { # 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 ($args[0] -eq 'login') { + return '[{"cloudName":"AzureCloud","state":"Enabled"}]' } - if ($Command -eq 'webapp') { - return "Deployment successful" + if ($args[0] -eq 'webapp') { + return '{"status":"Success"}' } - - return "" } } @@ -300,7 +250,7 @@ Describe 'Azure Deployment' { Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo Should -Invoke -CommandName 'az' -ParameterFilter { - $Command -eq 'login' + $args[0] -eq 'login' } -Times 1 } @@ -308,48 +258,84 @@ Describe 'Azure Deployment' { Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo Should -Invoke -CommandName 'az' -ParameterFilter { - $Command -eq 'webapp' + $args[0] -eq 'webapp' } -Times 1 } } ``` -## Testing Properties and Parameters - -Validate that properties are set correctly: +### Mocking AWS CLI ```powershell -Describe 'Build Properties' { +Describe 'AWS Deployment' { BeforeAll { $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' + Mock -CommandName 'aws' -MockWith { + if ($args[0] -eq 'deploy' -and $args[1] -eq 'create-deployment') { + return '{"deploymentId":"d-1234567890"}' + } + } } - It 'Default configuration is Debug' { - # Load build file - . $BuildFile - - # Check property - $psake.context.peek().config.Configuration | Should -Be 'Debug' + It 'Creates a CodeDeploy deployment' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList Deploy:AWS -NoLogo + $result.Success | Should -BeTrue + Should -Invoke -CommandName 'aws' -ParameterFilter { + $args[0] -eq 'deploy' -and $args[1] -eq 'create-deployment' + } -Times 1 } +} +``` - It 'Configuration can be overridden' { - $parameters = @{ - Configuration = 'Release' - } +## Testing Properties and Parameters - Invoke-psake -buildFile $BuildFile ` - -parameters $parameters ` - -taskList ShowConfig ` - -nologo +Put assertions that depend on build properties inside tasks, where psake properties are in scope: - # Verify configuration was set - # This requires the build script to expose configuration +```powershell +# psakefile.ps1 +Properties { + $Configuration = 'Debug' + $Environment = 'dev' +} + +Task ValidateDebugConfiguration { + Assert ($Configuration -eq 'Debug') 'Expected Debug configuration' +} + +Task ValidateReleaseConfiguration { + Assert ($Configuration -eq 'Release') 'Expected Release configuration' +} + +Task ValidateDevelopmentEnvironment { + Assert ($Environment -eq 'dev') 'Expected dev environment' +} + +Task ValidateStagingEnvironment { + Assert ($Environment -eq 'staging') 'Expected staging environment' +} +``` + +```powershell +Describe 'Build Properties' { + BeforeAll { + $script:BuildFile = Join-Path $PSScriptRoot '../psakefile.ps1' } - It 'Environment defaults to dev' { - . $BuildFile + It 'Uses the default Debug configuration' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList ValidateDebugConfiguration -NoLogo + $result.Success | Should -BeTrue + } - $psake.context.peek().config.Environment | Should -Be 'dev' + It 'Accepts a Release configuration override' { + $result = Invoke-psake -BuildFile $BuildFile ` + -Properties @{ Configuration = 'Release' } ` + -TaskList ValidateReleaseConfiguration -NoLogo + $result.Success | Should -BeTrue + } + + It 'Uses the default dev environment' { + $result = Invoke-psake -BuildFile $BuildFile -TaskList ValidateDevelopmentEnvironment -NoLogo + $result.Success | Should -BeTrue } } ``` @@ -372,7 +358,7 @@ Describe 'Complete Build Pipeline' { Context 'Full Build' { It 'Completes without errors' { $result = Invoke-psake -buildFile $BuildFile -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } It 'Creates build artifacts' { @@ -396,15 +382,13 @@ Describe 'Complete Build Pipeline' { Context 'Different Configurations' { It 'Debug build succeeds' { - $params = @{ Configuration = 'Debug' } - $result = Invoke-psake -buildFile $BuildFile -parameters $params -nologo - $result | Should -Be $true + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Debug' } -nologo + $result.Success | Should -BeTrue } It 'Release build succeeds' { - $params = @{ Configuration = 'Release' } - $result = Invoke-psake -buildFile $BuildFile -parameters $params -nologo - $result | Should -Be $true + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Release' } -nologo + $result.Success | Should -BeTrue } } @@ -431,38 +415,28 @@ Describe 'Error Handling' { } It 'Build fails when compilation fails' { - # Mock dotnet to return error - Mock -CommandName 'dotnet' -MockWith { - Write-Error "Compilation failed" - return 1 - } + Mock -CommandName 'dotnet' -MockWith { throw "Compilation failed" } $result = Invoke-psake -buildFile $BuildFile -taskList Compile -nologo - $result | Should -Be $false + $result.Success | Should -BeFalse } It 'Build fails when tests fail' { Mock -CommandName 'dotnet' -MockWith { - param($Command) - - if ($Command -eq 'test') { - Write-Error "Tests failed" - return 1 - } - return 0 + if ($args[0] -eq 'test') { throw "Tests failed" } } $result = Invoke-psake -buildFile $BuildFile -taskList Test -nologo - $result | Should -Be $false + $result.Success | Should -BeFalse } 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 + $result = Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + $result.Success | Should -BeFalse } finally { $env:API_KEY = $originalApiKey @@ -625,39 +599,36 @@ Describe 'Build Script Validation' { Test-Path $BuildFile | Should -Be $true } - It 'Build file is valid PowerShell' { - { . $BuildFile } | Should -Not -Throw + It 'Build file defines valid tasks' { + { Get-PSakeScriptTasks -BuildFile $BuildFile } | Should -Not -Throw } It 'Build tasks directory exists' { $tasksDir = Join-Path $ProjectRoot 'build/tasks' - Test-Path $tasksDir | Should -Be $true + Test-Path $tasksDir | Should -BeTrue } } Context 'Task Definitions' { BeforeAll { - . $BuildFile + $script:Tasks = Get-PSakeScriptTasks -BuildFile $BuildFile } It 'Defines Default task' { - $task = Get-PSakeScriptTask -taskName 'Default' - $task | Should -Not -BeNullOrEmpty + $Tasks.Name | Should -Contain 'Default' } It 'Defines Build task' { - $task = Get-PSakeScriptTask -taskName 'Build' - $task | Should -Not -BeNullOrEmpty + $Tasks.Name | Should -Contain 'Build' } It 'Defines Test task' { - $task = Get-PSakeScriptTask -taskName 'Test' - $task | Should -Not -BeNullOrEmpty + $Tasks.Name | Should -Contain 'Test' } It 'Test task depends on Build' { - $task = Get-PSakeScriptTask -taskName 'Test' - $task.DependsOn | Should -Contain 'Build' + $testTask = $Tasks | Where-Object Name -eq 'Test' + $testTask.DependsOn | Should -Contain 'Build' } } @@ -671,38 +642,36 @@ Describe 'Build Script Validation' { It 'Clean task executes successfully' { $result = Invoke-psake -buildFile $BuildFile -taskList Clean -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } It 'Build task executes successfully' { $result = Invoke-psake -buildFile $BuildFile -taskList Build -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } It 'Full pipeline executes successfully' { $result = Invoke-psake -buildFile $BuildFile -nologo - $result | Should -Be $true + $result.Success | Should -BeTrue } } 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 Configuration property' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Configuration = 'Release' } -taskList ValidateReleaseConfiguration -nologo + $result.Success | Should -BeTrue } - It 'Respects Environment parameter' { - $params = @{ Environment = 'staging' } - $result = Invoke-psake -buildFile $BuildFile -parameters $params -taskList ShowConfig -nologo - $result | Should -Be $true + It 'Respects Environment property' { + $result = Invoke-psake -buildFile $BuildFile -properties @{ Environment = 'staging' } -taskList ValidateStagingEnvironment -nologo + $result.Success | Should -BeTrue } } Context 'Error Handling' { It 'Fails gracefully on invalid task' { $result = Invoke-psake -buildFile $BuildFile -taskList InvalidTask -nologo - $result | Should -Be $false + $result.Success | Should -BeFalse } It 'Validates required environment variables' { @@ -710,7 +679,8 @@ Describe 'Build Script Validation' { $env:REQUIRED_VAR = $null try { - { Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo } | Should -Throw + $result = Invoke-psake -buildFile $BuildFile -taskList Deploy -nologo + $result.Success | Should -BeFalse } finally { $env:REQUIRED_VAR = $originalEnv diff --git a/docs/best-practices/versioning-strategy.md b/docs/best-practices/versioning-strategy.md index b75f71d..44aee7b 100644 --- a/docs/best-practices/versioning-strategy.md +++ b/docs/best-practices/versioning-strategy.md @@ -12,19 +12,10 @@ Proper version management ensures traceability, reproducibility, and clear relea 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+)') { + if ($tag -match '^v?(\d+\.\d+\.\d+)$') { return $matches[1] } } @@ -33,6 +24,12 @@ function Get-GitVersion { return '1.0.0' } +Properties { + $BaseVersion = Get-GitVersion + $BuildNumber = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { '0' } + $Version = "$BaseVersion-ci.$BuildNumber" +} + Task Build { Write-Host "Building version: $Version" -ForegroundColor Cyan @@ -137,54 +134,20 @@ 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] +The `Get-GitVersion` helper from the quick start validates tags as `X.Y.Z`. Add commit distance when the checkout is ahead of the tag: - # 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' +```powershell +Properties { + $LatestTag = git describe --tags --abbrev=0 2>$null + $BaseVersion = Get-GitVersion + $CommitsSinceTag = if ($LatestTag) { git rev-list "$LatestTag..HEAD" --count 2>$null } else { 0 } + $Version = if ($CommitsSinceTag -gt 0) { + "$BaseVersion-dev.$CommitsSinceTag" + } else { + $BaseVersion } } -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 @@ -263,15 +226,15 @@ Task Build -depends GetGitVersion { mode: Mainline branches: main: - tag: '' + label: '' develop: - tag: 'beta' + label: 'beta' feature: - tag: 'alpha.{BranchName}' + label: 'alpha.{BranchName}' release: - tag: 'rc' + label: 'rc' hotfix: - tag: 'hotfix' + label: 'hotfix' ignore: sha: [] ``` @@ -288,8 +251,8 @@ Properties { $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" + # SemVer build identity and informational metadata + $Version = "$BaseVersion-ci.$BuildNumber" $InformationalVersion = "$Version+$GitSha" } @@ -340,8 +303,8 @@ jobs: ```powershell Properties { $BaseVersion = '1.0.0' - $BuildNumber = if ($env:BUILD_BUILDNUMBER) { $env:BUILD_BUILDNUMBER } else { '0' } - $Version = "$BaseVersion.$BuildNumber" + $BuildNumber = if ($env:BUILD_BUILDID) { $env:BUILD_BUILDID } else { '0' } + $Version = "$BaseVersion-ci.$BuildNumber" } Task Build { @@ -389,54 +352,48 @@ Automatically update project file versions: function Update-ProjectVersion { param( [string]$ProjectFile, - [string]$Version + [string]$Version, + [string]$AssemblyVersion ) 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 + $versions = @{ + Version = $Version + InformationalVersion = $Version + AssemblyVersion = $AssemblyVersion + FileVersion = $AssemblyVersion + } + foreach ($entry in $versions.GetEnumerator()) { + if ($null -eq $propertyGroup.($entry.Key)) { + $propertyGroup.AppendChild($project.CreateElement($entry.Key)) | Out-Null } - $propertyGroup.$propName = $Version + $propertyGroup.($entry.Key) = $entry.Value } - # 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 + Update-ProjectVersion ` + -ProjectFile $project.FullName ` + -Version $Version ` + -AssemblyVersion "$MajorVersion.$MinorVersion.$PatchVersion.0" } Write-Host "Updated $($projects.Count) project files" -ForegroundColor Green @@ -453,20 +410,17 @@ Task Build -depends UpdateProjectVersions { function Update-AssemblyInfo { param( [string]$AssemblyInfoPath, - [string]$Version + [string]$Version, + [string]$AssemblyVersion ) 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: AssemblyVersion\(".*?"\)\]', "[assembly: AssemblyVersion(""$AssemblyVersion"")]" + $content = $content -replace '\[assembly: AssemblyFileVersion\(".*?"\)\]', "[assembly: AssemblyFileVersion(""$AssemblyVersion"")]" $content = $content -replace '\[assembly: AssemblyInformationalVersion\(".*?"\)\]', "[assembly: AssemblyInformationalVersion(""$Version"")]" Set-Content -Path $AssemblyInfoPath -Value $content @@ -478,7 +432,10 @@ Task UpdateAssemblyInfoFiles { $assemblyInfoFiles = Get-ChildItem "$SrcDir/**/AssemblyInfo.cs" -Recurse foreach ($file in $assemblyInfoFiles) { - Update-AssemblyInfo -AssemblyInfoPath $file.FullName -Version $Version + Update-AssemblyInfo ` + -AssemblyInfoPath $file.FullName ` + -Version $Version ` + -AssemblyVersion "$MajorVersion.$MinorVersion.$PatchVersion.0" } } ``` @@ -566,8 +523,7 @@ function Get-BuildVersion { # Determine pre-release label $preRelease = switch -Regex ($Branch) { '^main$|^master$' { - # Production release - return "$baseVersion.$BuildNumber" + return "$baseVersion-ci.$BuildNumber" } '^release/.*' { # Release candidate From 5b2d42c103af6cbe9e251d3609978b2c49026fb0 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Sun, 30 Aug 2026 20:50:59 +0000 Subject: [PATCH 3/3] Address documentation review comments --- docs/best-practices/environment-management.md | 7 ++++--- docs/best-practices/organizing-large-scripts.md | 4 ++-- docs/best-practices/secret-management.md | 13 +++++++------ docs/best-practices/versioning-strategy.md | 8 ++++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/best-practices/environment-management.md b/docs/best-practices/environment-management.md index 8417863..8c20b33 100644 --- a/docs/best-practices/environment-management.md +++ b/docs/best-practices/environment-management.md @@ -434,7 +434,7 @@ Task Build { } } - $appConfigPath = Join-Path $ProjectRoot 'src/appsettings.$Environment.json' + $appConfigPath = Join-Path $ProjectRoot "src/appsettings.$Environment.json" $appConfig | ConvertTo-Json -Depth 10 | Set-Content $appConfigPath exec { dotnet build -c $Configuration } @@ -872,10 +872,11 @@ stages: ```powershell Task Debug:ShowEnvironment { + $environmentConfig = Join-Path $PSScriptRoot "build/config/$Environment.ps1" Write-Host "BUILD_ENV: $($env:BUILD_ENV)" -ForegroundColor Yellow Write-Host "Environment: $Environment" -ForegroundColor Yellow - Write-Host "Config File: $envConfigFile" -ForegroundColor Yellow - Write-Host "File Exists: $(Test-Path $envConfigFile)" -ForegroundColor Yellow + Write-Host "Config File: $environmentConfig" -ForegroundColor Yellow + Write-Host "File Exists: $(Test-Path $environmentConfig)" -ForegroundColor Yellow } ``` diff --git a/docs/best-practices/organizing-large-scripts.md b/docs/best-practices/organizing-large-scripts.md index b60566b..61d78f0 100644 --- a/docs/best-practices/organizing-large-scripts.md +++ b/docs/best-practices/organizing-large-scripts.md @@ -280,7 +280,7 @@ Task TestWithCoverage -depends Compile { $coverageFile = Get-ChildItem "$TestResultsDir/**/coverage.cobertura.xml" -Recurse | Select-Object -First 1 if ($coverageFile) { - [xml]$coverage = Get-Content $coverageFile.FullName + [xml]$coverage = Get-Content $coverageFile.FullName -Raw $lineRate = [double]$coverage.coverage.'line-rate' * 100 Write-Host "Code coverage: ${lineRate}%" -ForegroundColor Cyan @@ -624,7 +624,7 @@ function Set-AssemblyVersion { throw "Project file not found: $ProjectFile" } - [xml]$project = Get-Content $ProjectFile + [xml]$project = Get-Content $ProjectFile -Raw $propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1 diff --git a/docs/best-practices/secret-management.md b/docs/best-practices/secret-management.md index 5edc453..80692b2 100644 --- a/docs/best-practices/secret-management.md +++ b/docs/best-practices/secret-management.md @@ -31,9 +31,10 @@ Task Deploy { throw "DB_PASSWORD environment variable is required" } - # Use secrets (they won't appear in logs with -errorMessage) + # Expose the secret only through the child process environment. exec { - dotnet publish --api-key $ApiKey + $env:MYAPP_API_KEY = $ApiKey + try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY } } -errorMessage "Publish failed (credentials redacted)" } ``` @@ -432,7 +433,7 @@ deploy/ ```powershell Task Deploy { Write-Host "Using API key: $ApiKey" -ForegroundColor Gray # NEVER DO THIS! - exec { dotnet publish --api-key $ApiKey } + exec { dotnet publish } } ``` @@ -442,9 +443,9 @@ Task Deploy { Task Deploy { Write-Host "Using API key: [REDACTED]" -ForegroundColor Gray - # Use custom error message to avoid exposing secrets exec { - dotnet publish --api-key $ApiKey + $env:MYAPP_API_KEY = $ApiKey + try { dotnet publish } finally { Remove-Item Env:MYAPP_API_KEY } } -errorMessage "Publish failed (check API key configuration)" } ``` @@ -723,7 +724,7 @@ Task GrantKeyVaultAccess { exec { az keyvault set-policy ` - --name $KeyVaultName ` + --name $env:AZURE_KEYVAULT_NAME ` --spn $servicePrincipalId ` --secret-permissions get list } diff --git a/docs/best-practices/versioning-strategy.md b/docs/best-practices/versioning-strategy.md index 44aee7b..356757e 100644 --- a/docs/best-practices/versioning-strategy.md +++ b/docs/best-practices/versioning-strategy.md @@ -360,7 +360,7 @@ function Update-ProjectVersion { throw "Project file not found: $ProjectFile" } - [xml]$project = Get-Content $ProjectFile + [xml]$project = Get-Content $ProjectFile -Raw $propertyGroup = $project.Project.PropertyGroup | Select-Object -First 1 if ($null -eq $propertyGroup) { $propertyGroup = $project.CreateElement('PropertyGroup') @@ -388,7 +388,7 @@ function Update-ProjectVersion { Task UpdateProjectVersions { Write-Host "Updating project versions..." -ForegroundColor Green - $projects = Get-ChildItem "$SrcDir/**/*.csproj" -Recurse + $projects = Get-ChildItem -Path $SrcDir -Filter *.csproj -Recurse foreach ($project in $projects) { Update-ProjectVersion ` -ProjectFile $project.FullName ` @@ -573,10 +573,10 @@ Task UpdateVersions -depends ShowVersion { Write-Host "Updating project versions..." -ForegroundColor Green # Update .NET projects - $projects = Get-ChildItem "$SrcDir/**/*.csproj" -Recurse + $projects = Get-ChildItem -Path $SrcDir -Filter *.csproj -Recurse foreach ($project in $projects) { - [xml]$proj = Get-Content $project.FullName + [xml]$proj = Get-Content $project.FullName -Raw $propertyGroup = $proj.Project.PropertyGroup | Select-Object -First 1