Pipeline Best Practices

What Makes a Good Pipeline?

A well-designed CI/CD pipeline is fast, reliable, maintainable, and provides clear feedback. Following established best practices ensures pipelines support development velocity while maintaining code quality and security standards.

Effective pipelines balance speed with thoroughness, catching issues early while minimizing friction in the development workflow.


Core Pipeline Principles

Fast Feedback

Provide quick feedback on code changes to enable rapid iteration.

# Optimize for fast feedback stages: - stage: QuickChecks jobs: - job: Lint steps: - script: npm run lint - job: UnitTests steps: - script: npm test -- --watch=false - stage: SlowerChecks dependsOn: QuickChecks jobs: - job: IntegrationTests steps: - script: npm run test:integration

Why it matters: Developers context-switch frequently. Fast feedback reduces waiting time and maintains focus.

Fail Fast

Stop pipelines early when critical issues are detected.

stages: - stage: Validate jobs: - job: Lint steps: - script: npm run lint - job: CompileCheck steps: - script: npm run build # Only run if validation passes - stage: Test dependsOn: Validate condition: succeeded() jobs: - job: Tests steps: - script: npm test

Build Once, Deploy Many

Build artifacts once and deploy the same artifact to all environments.

stages: - stage: Build jobs: - job: BuildJob steps: - script: docker build -t myapp:$(Build.BuildId) . - script: docker push myapp:$(Build.BuildId) - stage: DeployDev jobs: - job: Deploy steps: - script: docker pull myapp:$(Build.BuildId) - script: docker run myapp:$(Build.BuildId) - stage: DeployProd jobs: - job: Deploy steps: - script: docker pull myapp:$(Build.BuildId) - script: docker run myapp:$(Build.BuildId)

Why it matters: Ensures consistency across environments and eliminates "works in staging but not production" issues.


Pipeline Architecture Pattern

graph TD A["Code Commit"] -->B["Quick Validation
Lint, Format, Compile"] B -->|Pass| C["Build Artifacts
Once"] C -->D["Unit Tests
Fast"] D -->E["Integration Tests
Slower"] E -->F["Security Scan"] F -->G["Deploy Dev"] G -->H["Deploy Staging"] H -->I["Deploy Production"] B -->|Fail| Z["Stop Pipeline"] style B fill:#e1f5ff style C fill:#fff3e0 style G fill:#e8f5e9 style H fill:#e8f5e9 style I fill:#ccffcc

Performance Optimization

Parallel Execution

Run independent jobs in parallel.

jobs: # These run in parallel - job: UnitTests steps: - script: npm run test:unit - job: Lint steps: - script: npm run lint - job: SecurityScan steps: - script: npm audit # This waits for all above - job: IntegrationTests dependsOn: - UnitTests - Lint - SecurityScan steps: - script: npm run test:integration

Caching Dependencies

Cache package installations to reduce build time.

# GitHub Actions steps: - uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - run: npm ci # Azure Pipelines steps: - task: Cache@2 inputs: key: 'npm | "$(Agent.OS)" | package-lock.json' path: $(npm_config_cache) - script: npm ci

Incremental Builds

Build only changed components in monorepos.

# Example with Nx steps: - script: npx nx affected:build --base=origin/main - script: npx nx affected:test --base=origin/main

Code Quality Gates

Automated Quality Checks

stages: - stage: QualityGate jobs: - job: CodeQuality steps: - script: npm run lint displayName: 'Linting' - script: npm run format:check displayName: 'Format check' - script: npm test -- --coverage displayName: 'Unit tests with coverage' - script: | # Fail if coverage below 80% COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') if (( $(echo "$COVERAGE < 80" | bc -l) )); then echo "Coverage $COVERAGE% is below 80%" exit 1 fi displayName: 'Coverage threshold check'

Branch Protection

Require pipeline success before merging:

# GitHub branch protection rules # Settings > Branches > Branch protection rules # - Require status checks to pass before merging # - Require branches to be up to date before merging # Azure DevOps branch policies # Repos > Branches > Branch policies # - Require a build to succeed # - Minimum number of reviewers

Security Best Practices

Secret Management

# BAD - Never hardcode secrets steps: - script: | export API_KEY="hardcoded-secret" curl -H "Authorization: Bearer hardcoded-secret" # GOOD - Use secret management steps: - script: | curl -H "Authorization: Bearer ${{ secrets.API_KEY }}" env: API_KEY: ${{ secrets.API_KEY }}

Container Image Scanning

steps: - script: docker build -t myapp:latest . - script: | # Scan image for vulnerabilities trivy image --severity HIGH,CRITICAL myapp:latest displayName: 'Security scan' - script: docker push myapp:latest condition: succeeded()

Dependency Scanning

steps: - script: npm audit --audit-level=high displayName: 'Audit dependencies' - script: | # Check for outdated dependencies npx npm-check-updates --errorLevel 2 displayName: 'Check for outdated packages'

Error Handling and Resilience

Retry Logic

# GitHub Actions steps: - name: Deploy with retry uses: nick-invision/retry@v2 with: timeout_minutes: 10 max_attempts: 3 command: npm run deploy # Azure Pipelines steps: - powershell: | $retries = 3 $success = $false for ($i = 0; $i -lt $retries; $i++) { try { npm run deploy $success = $true break } catch { Write-Host "Attempt $($i+1) failed" Start-Sleep -Seconds 10 } } if (-not $success) { throw "Deployment failed after $retries attempts" }

Conditional Execution

steps: - script: npm test displayName: 'Run tests' - script: echo "Tests passed" condition: succeeded() - script: echo "Tests failed, sending notification" condition: failed() - script: echo "Always runs cleanup" condition: always()

Timeout Configuration

jobs: - job: Build timeoutInMinutes: 30 steps: - script: npm run build timeoutInMinutes: 10

Maintainability

Pipeline as Code

Store pipeline definitions in version control:

project/ .github/ workflows/ ci.yml deploy.yml .azure/ pipelines/ ci-pipeline.yml deploy-pipeline.yml

Template and Reusability

# templates/node-build.yml parameters: - name: nodeVersion type: string default: '18' steps: - task: NodeTool@0 inputs: versionSpec: ${{ parameters.nodeVersion }} - script: npm ci - script: npm run build - script: npm test # Main pipeline jobs: - job: Build steps: - template: templates/node-build.yml parameters: nodeVersion: '20'

Clear Naming and Documentation

# GOOD - Clear names stages: - stage: BuildAndTest displayName: 'Build Application and Run Tests' jobs: - job: CompileCode displayName: 'Compile TypeScript Code' steps: - script: npm run build displayName: 'Run TypeScript compiler' # AVOID - Vague names stages: - stage: Stage1 jobs: - job: Job1 steps: - script: npm run build

Testing Strategy

Test Pyramid

graph TD A["E2E Tests
Slow, Expensive"] -->B["Integration Tests
Medium Speed"] B -->C["Unit Tests
Fast, Cheap"] style A fill:#ffcccc style B fill:#fff3e0 style C fill:#ccffcc

Pipeline implementation:

stages: - stage: FastTests jobs: - job: UnitTests steps: - script: npm run test:unit displayName: 'Unit tests (70% of tests)' - stage: MediumTests dependsOn: FastTests jobs: - job: IntegrationTests steps: - script: npm run test:integration displayName: 'Integration tests (20% of tests)' - stage: SlowTests dependsOn: MediumTests condition: eq(variables['Build.SourceBranch'], 'refs/heads/main') jobs: - job: E2ETests steps: - script: npm run test:e2e displayName: 'E2E tests (10% of tests)'

Smoke Tests

stages: - stage: Deploy jobs: - deployment: DeployApp steps: - script: kubectl apply -f deployment.yaml - stage: SmokeTests dependsOn: Deploy jobs: - job: Smoke steps: - script: | # Wait for deployment sleep 30 # Health check curl -f https://myapp.com/health || exit 1 # Basic functionality curl -f https://myapp.com/api/status || exit 1 displayName: 'Smoke tests'

Notifications and Monitoring

Pipeline Notifications

# GitHub Actions steps: - name: Notify Slack on failure if: failure() uses: slackapi/slack-github-action@v1 with: payload: | { "text": "Build failed: ${{ github.repository }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} # Azure Pipelines - Configure in project settings # Project Settings > Notifications > New subscription # Event: Build completed # Filter: Build status = Failed

Pipeline Metrics

Track key metrics:

steps: - script: | # Send metrics to monitoring system curl -X POST https://metrics.example.com/pipeline \ -d "build_duration=$(($SECONDS))" \ -d "build_status=${{ job.status }}" \ -d "build_id=${{ github.run_id }}"

Environment-Specific Configuration

Configuration Management

# Use environment variables stages: - stage: DeployDev variables: ENVIRONMENT: 'development' API_URL: 'https://api-dev.example.com' DATABASE_NAME: 'myapp_dev' jobs: - job: Deploy steps: - script: | echo "Deploying to $ENVIRONMENT" echo "API_URL=$API_URL" > .env echo "DATABASE_NAME=$DATABASE_NAME" >> .env - stage: DeployProd variables: ENVIRONMENT: 'production' API_URL: 'https://api.example.com' DATABASE_NAME: 'myapp_prod' jobs: - job: Deploy steps: - script: | echo "Deploying to $ENVIRONMENT" echo "API_URL=$API_URL" > .env echo "DATABASE_NAME=$DATABASE_NAME" >> .env

Documentation

Pipeline Documentation

# Add comments to complex logic steps: # Calculate semantic version based on commit messages # Format: major.minor.patch (e.g., 1.2.3) - script: | VERSION=$(conventional-changelog-cli --preset angular --output-unreleased) echo "##vso[task.setvariable variable=version]$VERSION" displayName: 'Calculate semantic version' # Tag repository with new version - script: | git tag v$(version) git push origin v$(version) displayName: 'Create and push git tag'

README for Pipelines

# CI/CD Pipeline Documentation ## Overview This pipeline builds, tests, and deploys the application. ## Triggers - Push to `main` or `develop` branches - Pull requests to `main` - Nightly scheduled builds at 2 AM UTC ## Stages 1. Build - Compile and package application 2. Test - Run unit and integration tests 3. Deploy Dev - Auto-deploy to development 4. Deploy Staging - Deploy to staging (main branch only) 5. Deploy Production - Deploy to production (manual approval required) ## Variables - `buildConfiguration`: Release/Debug build mode - `nodeVersion`: Node.js version to use ## Secrets - `AZURE_CREDENTIALS`: Azure service principal - `DATABASE_PASSWORD`: Production database password

Common Pitfalls to Avoid

Don't Mix Configuration with Code

# BAD steps: - script: | if [ "$BRANCH" == "main" ]; then export API_URL="https://api.example.com" else export API_URL="https://api-dev.example.com" fi # GOOD variables: - ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}: - name: API_URL value: 'https://api.example.com' - ${{ else }}: - name: API_URL value: 'https://api-dev.example.com'

Don't Ignore Failed Tests

# BAD steps: - script: npm test || true # GOOD steps: - script: npm test - script: echo "Cleanup even if tests fail" condition: always()

Don't Skip Security Scans

# Always include security checks stages: - stage: Security jobs: - job: Scan steps: - script: npm audit - script: trivy image myapp:latest

Real-World Example: Complete Pipeline

name: $(Date:yyyyMMdd)$(Rev:.r) trigger: branches: include: - main - develop variables: - group: shared-variables - name: buildConfiguration value: 'Release' - name: nodeVersion value: '18.x' stages: - stage: Validate displayName: 'Quick Validation' jobs: - job: Lint pool: vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 inputs: versionSpec: $(nodeVersion) - script: npm ci displayName: 'Install dependencies' - script: npm run lint displayName: 'Run linter' - script: npm run format:check displayName: 'Check formatting' - stage: Build displayName: 'Build Application' dependsOn: Validate jobs: - job: BuildJob pool: vmImage: 'ubuntu-latest' steps: - template: templates/node-setup.yml - script: npm run build displayName: 'Build application' - task: CopyFiles@2 inputs: SourceFolder: 'dist' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: PublishBuildArtifacts@1 inputs: pathToPublish: '$(Build.ArtifactStagingDirectory)' artifactName: 'webapp' - stage: Test displayName: 'Run Tests' dependsOn: Build jobs: - job: UnitTests steps: - template: templates/node-setup.yml - script: npm run test:unit -- --coverage displayName: 'Unit tests' - task: PublishCodeCoverageResults@1 inputs: codeCoverageTool: 'Cobertura' summaryFileLocation: 'coverage/cobertura-coverage.xml' - job: IntegrationTests steps: - template: templates/node-setup.yml - script: npm run test:integration displayName: 'Integration tests' - stage: Security displayName: 'Security Scanning' dependsOn: Build jobs: - job: SecurityScan steps: - script: npm audit --audit-level=moderate displayName: 'Audit dependencies' - script: trivy fs --severity HIGH,CRITICAL . displayName: 'Trivy scan' - stage: DeployDev displayName: 'Deploy to Development' dependsOn: - Test - Security condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop')) jobs: - deployment: DeployDev environment: development strategy: runOnce: deploy: steps: - template: templates/deploy-steps.yml parameters: environment: 'dev' - stage: DeployProd displayName: 'Deploy to Production' dependsOn: - Test - Security condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) jobs: - deployment: DeployProd environment: production strategy: runOnce: deploy: steps: - template: templates/deploy-steps.yml parameters: environment: 'prod' on: failure: steps: - script: echo "Sending failure notification" success: steps: - script: echo "Deployment successful"

Key Takeaways

Next Steps: Review existing pipelines against these best practices, implement caching and parallelization, add quality gates, and create reusable templates for common patterns.