CI/CD Pipeline Mastery: GitHub Actions and Azure DevOps for Modern Development

CI/CD Pipeline Mastery: GitHub Actions and Azure DevOps for Modern Development

Introduction

Continuous Integration and Continuous Deployment (CI/CD) pipelines automate the software delivery process from code commit to production deployment. This guide covers implementing robust CI/CD pipelines using GitHub Actions for lightweight workflows and Azure DevOps Pipelines for enterprise-scale deployments, including testing strategies, security scanning, and deployment patterns.

Why CI/CD Matters

CI/CD pipelines provide:

  • Early Bug Detection: Automated tests catch issues before production
  • Faster Releases: Eliminate manual deployment steps
  • Consistency: Every deployment follows the same process
  • Traceability: Complete audit trail of changes
  • Confidence: Validated builds reduce deployment anxiety

GitHub Actions Fundamentals

Basic Workflow Structure

.github/workflows/ci.yml:

name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --configuration Release --no-restore
      
      - name: Run tests
        run: dotnet test --no-build --verbosity normal

Multi-Environment Workflows

Conditional Deployments:

name: Deploy to Environments

on:
  push:
    branches:
      - main
      - develop

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build application
        run: npm run build
      
      - name: Upload artifact
        uses: actions/upload-artifact@v3
        with:
          name: webapp
          path: dist/

  deploy-dev:
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: development
    steps:
      - uses: actions/download-artifact@v3
        with:
          name: webapp
      
      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v2
        with:
          app-name: webapp-dev
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE_DEV }}
          package: .

  deploy-prod:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.contoso.com
    steps:
      - uses: actions/download-artifact@v3
        with:
          name: webapp
      
      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v2
        with:
          app-name: webapp-prod
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE_PROD }}
          package: .

Matrix Builds

Test Across Multiple Versions:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18.x, 20.x, 22.x]
        exclude:
          - os: macos-latest
            node-version: 18.x
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      
      - run: npm ci
      - run: npm test

Reusable Workflows

.github/workflows/reusable-build.yml:

name: Reusable Build Workflow

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      deploy-key:
        required: true

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      
      - run: npm ci
      - run: npm run build:${{ inputs.environment }}
      
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.deploy-key }}
        run: ./deploy.sh

Call Reusable Workflow:

jobs:
  deploy-dev:
    uses: ./.github/workflows/reusable-build.yml
    with:
      environment: dev
      node-version: '20'
    secrets:
      deploy-key: ${{ secrets.DEV_DEPLOY_KEY }}

Azure DevOps Pipelines

YAML Pipeline Structure

azure-pipelines.yml:

trigger:
  branches:
    include:
      - main
      - develop
  paths:
    include:
      - src/*
    exclude:
      - docs/*

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '8.0.x'

stages:
  - stage: Build
    displayName: 'Build Application'
    jobs:
      - job: BuildJob
        displayName: 'Build and Test'
        steps:
          - task: UseDotNet@2
            displayName: 'Install .NET SDK'
            inputs:
              version: $(dotnetVersion)
          
          - task: DotNetCoreCLI@2
            displayName: 'Restore dependencies'
            inputs:
              command: 'restore'
              projects: '**/*.csproj'
          
          - task: DotNetCoreCLI@2
            displayName: 'Build solution'
            inputs:
              command: 'build'
              projects: '**/*.csproj'
              arguments: '--configuration $(buildConfiguration)'
          
          - task: DotNetCoreCLI@2
            displayName: 'Run unit tests'
            inputs:
              command: 'test'
              projects: '**/*Tests.csproj'
              arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage"'
          
          - task: PublishCodeCoverageResults@1
            displayName: 'Publish code coverage'
            inputs:
              codeCoverageTool: 'Cobertura'
              summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
          
          - task: DotNetCoreCLI@2
            displayName: 'Publish application'
            inputs:
              command: 'publish'
              publishWebProjects: true
              arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
          
          - task: PublishBuildArtifacts@1
            displayName: 'Publish artifacts'
            inputs:
              pathToPublish: '$(Build.ArtifactStagingDirectory)'
              artifactName: 'drop'

  - stage: Deploy_Dev
    displayName: 'Deploy to Dev'
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
    jobs:
      - deployment: DeployDev
        environment: 'development'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  displayName: 'Deploy to Azure Web App'
                  inputs:
                    azureSubscription: 'Azure-Dev'
                    appName: 'webapp-dev'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'

  - stage: Deploy_Prod
    displayName: 'Deploy to Production'
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployProd
        environment: 'production'
        strategy:
          runOnce:
            preDeploy:
              steps:
                - script: echo "Running pre-deployment checks"
            deploy:
              steps:
                - task: AzureWebApp@1
                  displayName: 'Deploy to Azure Web App'
                  inputs:
                    azureSubscription: 'Azure-Prod'
                    appName: 'webapp-prod'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'
                    deploymentMethod: 'zipDeploy'
            postDeploy:
              steps:
                - script: curl -f https://webapp-prod.azurewebsites.net/health || exit 1
                  displayName: 'Health check'

Pipeline Templates

templates/build-template.yml:

parameters:
  - name: projectPath
    type: string
  - name: buildConfiguration
    type: string
    default: 'Release'

steps:
  - task: UseDotNet@2
    inputs:
      version: '8.0.x'
  
  - task: DotNetCoreCLI@2
    displayName: 'Restore'
    inputs:
      command: 'restore'
      projects: '${{ parameters.projectPath }}'
  
  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: 'build'
      projects: '${{ parameters.projectPath }}'
      arguments: '--configuration ${{ parameters.buildConfiguration }}'

Use Template:

stages:
  - stage: Build
    jobs:
      - job: BuildAPI
        steps:
          - template: templates/build-template.yml
            parameters:
              projectPath: 'src/API/API.csproj'
              buildConfiguration: 'Release'
      
      - job: BuildWorker
        steps:
          - template: templates/build-template.yml
            parameters:
              projectPath: 'src/Worker/Worker.csproj'

Testing Strategies

Unit Tests in CI

GitHub Actions:

- name: Run unit tests with coverage
  run: |
    dotnet test \
      --configuration Release \
      --no-build \
      --collect:"XPlat Code Coverage" \
      --results-directory ./coverage

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v3
  with:
    files: ./coverage/**/coverage.cobertura.xml
    fail_ci_if_error: true

Integration Tests

Docker Compose for Dependencies:

- name: Start dependencies
  run: docker-compose -f docker-compose.test.yml up -d

- name: Wait for services
  run: |
    timeout 60 bash -c 'until docker exec postgres pg_isready; do sleep 1; done'

- name: Run integration tests
  run: dotnet test IntegrationTests.csproj --filter Category=Integration

- name: Teardown
  if: always()
  run: docker-compose -f docker-compose.test.yml down

End-to-End Tests

Playwright E2E Tests:

- name: Install Playwright
  run: npx playwright install --with-deps

- name: Run E2E tests
  run: npx playwright test
  env:
    BASE_URL: https://webapp-staging.azurewebsites.net

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v3
  with:
    name: playwright-report
    path: playwright-report/

Security Scanning

GitHub Advanced Security

- name: Initialize CodeQL
  uses: github/codeql-action/init@v3
  with:
    languages: csharp, javascript

- name: Autobuild
  uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
  uses: github/codeql-action/analyze@v3

Dependency Scanning

- name: Run Snyk security scan
  uses: snyk/actions/dotnet@master
  env:
    SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
  with:
    args: --severity-threshold=high

Container Image Scanning

- name: Build Docker image
  run: docker build -t myapp:${{ github.sha }} .

- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    format: 'sarif'
    output: 'trivy-results.sarif'

- name: Upload results to GitHub Security
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: 'trivy-results.sarif'

Deployment Patterns

Blue-Green Deployment

Azure DevOps:

- task: AzureAppServiceManage@0
  displayName: 'Swap deployment slots'
  inputs:
    azureSubscription: 'Azure-Prod'
    action: 'Swap Slots'
    webAppName: 'webapp-prod'
    resourceGroupName: 'rg-prod'
    sourceSlot: 'staging'
    targetSlot: 'production'

Canary Deployment

GitHub Actions with Traffic Splitting:

- name: Deploy canary (10% traffic)
  uses: azure/webapps-deploy@v2
  with:
    app-name: webapp-prod
    slot-name: canary
    package: .

- name: Route 10% traffic to canary
  run: |
    az webapp traffic-routing set \
      --resource-group rg-prod \
      --name webapp-prod \
      --distribution canary=10

- name: Monitor metrics
  run: ./scripts/monitor-canary.sh
  timeout-minutes: 30

- name: Promote canary or rollback
  run: |
    if [ "$CANARY_SUCCESS" = "true" ]; then
      az webapp traffic-routing set --distribution canary=100
    else
      az webapp traffic-routing clear
    fi

Pipeline Optimization

Caching Dependencies

GitHub Actions:

- name: Cache npm dependencies
  uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Cache NuGet packages
  uses: actions/cache@v3
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}

Azure DevOps:

- task: Cache@2
  inputs:
    key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
    path: $(NUGET_PACKAGES)
  displayName: 'Cache NuGet packages'

Parallel Jobs

GitHub Actions:

jobs:
  test-unit:
    runs-on: ubuntu-latest
    steps: [...]
  
  test-integration:
    runs-on: ubuntu-latest
    steps: [...]
  
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  
  security-scan:
    runs-on: ubuntu-latest
    steps: [...]

Monitoring & Notifications

Slack Notifications

GitHub Actions:

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "Build failed: ${{ github.repository }} - ${{ github.ref }}"
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Application Insights Integration

Azure DevOps:

- task: AzureCLI@2
  displayName: 'Track deployment'
  inputs:
    azureSubscription: 'Azure-Prod'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      az monitor app-insights events custom \
        --app webapp-prod-insights \
        --event-type Deployment \
        --properties '{"version":"$(Build.BuildNumber)","result":"success"}'

Best Practices

  1. Fail Fast: Run quick tests (linting, unit tests) before expensive operations
  2. Immutable Artifacts: Build once, deploy everywhere
  3. Environment Parity: Keep dev/staging/prod as similar as possible
  4. Secrets Management: Use GitHub Secrets or Azure Key Vault, never hardcode
  5. Version Control Everything: Pipelines, configuration, infrastructure
  6. Branch Protection: Require CI success before merging to main
  7. Deployment Gates: Manual approvals for production deployments
  8. Rollback Strategy: Automated rollback on health check failures

Troubleshooting

Pipeline Failures:

# GitHub Actions debug mode
# Add secret: ACTIONS_STEP_DEBUG = true

# Azure DevOps diagnostic logging
System.Debug: true

Intermittent Test Failures:

  • Add retry logic for flaky tests
  • Increase timeouts for network calls
  • Use test isolation strategies

Key Takeaways

  • GitHub Actions excels at simplicity and GitHub integration
  • Azure DevOps provides enterprise features (approvals, gates, extensions)
  • Automate security scanning in every pipeline run
  • Cache dependencies to reduce build times
  • Implement deployment strategies (blue-green, canary) for zero-downtime releases

Next Steps

  • Explore GitHub Environments for deployment protection rules
  • Implement Infrastructure as Code with Bicep or Terraform in pipelines
  • Add smoke tests post-deployment for immediate validation
  • Configure branch policies to enforce CI success

Additional Resources


Automate everything, deploy with confidence.