Learnixo

GitHub Actions · Lesson 4 of 5

Deployment Pipelines to Azure with GitHub Actions

Complete CD Workflow

YAML
# .github/workflows/cd.yml
# Triggers on merge to main  build, test, deploy to staging, then await approval for production

name: CD

on:
  push:
    branches: [main]

permissions:
  id-token: write  # OIDC
  contents: read

env:
  DOTNET_VERSION: '8.x'
  APP_NAME: 'clinical-platform'
  RESOURCE_GROUP: 'clinical-rg'

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Build and publish
        run: |
          dotnet publish src/Host/SystemForge.Api/SystemForge.Api.csproj \
            --configuration Release \
            --output ./publish

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-publish
          path: ./publish

Staging Deployment

YAML
  deploy-staging:
    runs-on: ubuntu-latest
    needs: build
    environment: staging

    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: app-publish
          path: ./publish

      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id:       ${{ vars.AZURE_CLIENT_ID }}
          tenant-id:       ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name:  ${{ env.APP_NAME }}
          slot-name: staging
          package:   ./publish

      - name: Run smoke tests against staging
        run: |
          # Wait for App Service to become healthy
          for i in $(seq 1 12); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              "https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/live")
            if [ "$STATUS" = "200" ]; then
              echo "Staging is healthy"
              break
            fi
            echo "Waiting... HTTP $STATUS"
            sleep 10
          done

          # Run a functional smoke test
          curl -f \
            "https://${{ env.APP_NAME }}-staging.azurewebsites.net/api/health" \
            -H "Accept: application/json"

Production Deployment with Approval Gate

YAML
  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production  # requires reviewer approval in GitHub Environments

    steps:
      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id:       ${{ vars.AZURE_CLIENT_ID }}
          tenant-id:       ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Swap staging  production
        uses: azure/cli@v2
        with:
          inlineScript: |
            az webapp deployment slot swap \
              --resource-group ${{ env.RESOURCE_GROUP }} \
              --name ${{ env.APP_NAME }} \
              --slot staging \
              --target-slot production

      - name: Verify production health
        run: |
          for i in $(seq 1 10); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              "https://${{ env.APP_NAME }}.azurewebsites.net/health/live")
            if [ "$STATUS" = "200" ]; then
              echo "Production is healthy — deployment successful"
              exit 0
            fi
            echo "Attempt $i: HTTP $STATUS"
            sleep 15
          done
          echo "Production health check failed after deployment"
          exit 1

Rollback on Failure

YAML
  rollback-on-failure:
    runs-on: ubuntu-latest
    needs: deploy-production
    if: failure()  # only runs if deploy-production fails

    steps:
      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id:       ${{ vars.AZURE_CLIENT_ID }}
          tenant-id:       ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Swap back (rollback)
        uses: azure/cli@v2
        with:
          inlineScript: |
            # Swap production back to staging (reverses the swap)
            az webapp deployment slot swap \
              --resource-group ${{ env.RESOURCE_GROUP }} \
              --name ${{ env.APP_NAME }} \
              --slot production \
              --target-slot staging
            echo "Rollback complete — previous version restored to production"

      - name: Notify team of rollback
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Production deployment ROLLED BACK for ${{ github.repository }}. Commit: ${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Database Migration as a Deployment Step

YAML
# Run EF Core migrations BEFORE swapping to production
# Migrations must be backward-compatible with the current production code

  run-migrations:
    runs-on: ubuntu-latest
    needs: build
    environment: production  # uses production connection string

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Install EF Core tools
        run: dotnet tool install --global dotnet-ef

      - name: Run migrations
        run: |
          dotnet ef database update \
            --project src/Modules/Prescriptions/Prescriptions.Infrastructure \
            --startup-project src/Host/SystemForge.Api \
            --context PrescriptionsDbContext
        env:
          ConnectionStrings__Clinical: ${{ secrets.PRODUCTION_CONNECTION_STRING }}

# Run migrations before the slot swap  new schema is in place before new code goes live
# If migration fails, abort before the swap (production code still runs on old schema)

Deployment Notifications

YAML
      - name: Notify Slack on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployed to production: ${{ github.repository }}@${{ github.sha }}",
              "attachments": [{
                "color": "good",
                "text": "Commit: ${{ github.event.head_commit.message }}\nAuthor: ${{ github.actor }}"
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Production issue I've seen: A team's CD pipeline deployed new code to production before running database migrations. For 3 minutes, the new application code was talking to the old schema. A query that referenced a new column (warfarin_enhanced_check_required) failed for every prescription that was accessed during those 3 minutes — nurses received 500 errors. The migration then ran and everything worked. The fix: always run migrations first, then deploy the application code. For zero-downtime migrations: make the migration backward-compatible so the old code still works with the new schema (add nullable columns, don't remove or rename).


Key Takeaway

Structure the CD pipeline as: build → deploy staging → smoke test → await approval → swap to production → verify health → rollback on failure. Use OIDC for Azure authentication — no stored credentials. Protect the production environment in GitHub with required reviewers. Run database migrations before the slot swap, not after. Always include a rollback step triggered on failure. Notify the team on both success and failure via Slack or Teams integration.