Back to blog
AI Systemsintermediate

GitHub Actions Pipeline: Test → Build → Deploy

Build a complete CI/CD pipeline for an LLM service using GitHub Actions — automated tests, Docker image build and push to ACR, and deployment to Azure Container Apps with environment protection and secrets management.

Asma Hafeez KhanMay 15, 202611 min read
LLMOpsGitHub ActionsCI/CDDockerACRAzure Container AppsDevOps
Share:𝕏

What We're Building

A three-job GitHub Actions pipeline:

push to main
    │
    ▼
┌─────────┐     ┌──────────────────────────┐     ┌──────────────┐
│  test   │────▶│  build (on test success)  │────▶│    deploy    │
└─────────┘     └──────────────────────────┘     └──────────────┘
pytest           docker build                    az containerapp
mock Azure       push to ACR                     update --image

Each job is isolated: build only runs if tests pass, deploy only runs if build succeeds. This prevents broken code from reaching production, even if one step of the pipeline fails silently.


Prerequisites: Secrets in GitHub

Before writing the workflow, set up the required GitHub secrets. Go to Settings → Secrets and variables → Actions → New repository secret and add:

| Secret name | Value | Where it comes from | |---|---|---| | AZURE_CREDENTIALS | JSON service principal | az ad sp create-for-rbac --sdk-auth | | ACR_LOGIN_SERVER | pharmabotprod.azurecr.io | Your ACR resource | | ACR_USERNAME | Service principal app ID | From AZURE_CREDENTIALS | | ACR_PASSWORD | Service principal password | From AZURE_CREDENTIALS | | AZURE_RESOURCE_GROUP | rg-pharmabot-prod | Your resource group | | AZURE_CONTAINER_APP_NAME | pharmabot | Your Container App name |

Generate the service principal:

Bash
az ad sp create-for-rbac \
  --name "sp-pharmabot-github-actions" \
  --role contributor \
  --scopes /subscriptions/$(az account show --query id -o tsv)/resourceGroups/rg-pharmabot-prod \
  --sdk-auth

Copy the entire JSON output as the AZURE_CREDENTIALS secret. It looks like:

JSON
{
  "clientId": "...",
  "clientSecret": "...",
  "subscriptionId": "...",
  "tenantId": "...",
  ...
}

The Complete Workflow File

Create .github/workflows/deploy.yml:

YAML
name: Test  Build  Deploy

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

# Prevent concurrent deployments to the same environment
# If a new push arrives while a deploy is in progress, the old run is cancelled
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  PYTHON_VERSION: "3.11"
  IMAGE_NAME: pharmabot

jobs:

  # ══════════════════════════════════════════════════════════════════════════
  # JOB 1: TEST
  # Runs on every push and pull request.
  # Uses MOCK_AZURE=true to avoid real API calls.
  # ══════════════════════════════════════════════════════════════════════════
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      # Cache pip dependencies between runs
      # Cache key includes the hash of requirements.txt
      # If requirements.txt hasn't changed, the cache is restored in ~5 seconds
      - name: Cache pip dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run linting (ruff)
        run: ruff check app/ tests/

      - name: Run type checking (mypy)
        run: mypy app/ --ignore-missing-imports

      - name: Run tests
        env:
          # This env flag tells the DI container to use MockAzureOpenAI
          # instead of the real client — no API key needed
          MOCK_AZURE: "true"
          AZURE_OPENAI_DEPLOYMENT: "gpt-4o"
          AZURE_OPENAI_API_VERSION: "2024-02-01"
          REDIS_URL: "redis://localhost:6379"
          DATABASE_URL: "sqlite+aiosqlite:///./test.db"
          DEBUG: "true"
        run: |
          pytest tests/ \
            --cov=app \
            --cov-report=xml \
            --cov-report=term-missing \
            --cov-fail-under=80 \
            -v \
            --tb=short

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        if: always()
        with:
          file: ./coverage.xml
          fail_ci_if_error: false

  # ══════════════════════════════════════════════════════════════════════════
  # JOB 2: BUILD
  # Only runs on pushes to main (not PRs), and only if tests pass.
  # Builds the Docker image and pushes to Azure Container Registry.
  # ══════════════════════════════════════════════════════════════════════════
  build:
    name: Build and Push Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    timeout-minutes: 30

    # Output the image tag so the deploy job can reference it
    outputs:
      image-tag: ${{ steps.meta.outputs.image-tag }}
      image-digest: ${{ steps.push.outputs.digest }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Generate image metadata
        id: meta
        run: |
          GIT_SHA=$(git rev-parse --short HEAD)
          BUILD_DATE=$(date -u +%Y%m%dT%H%M%SZ)
          IMAGE_TAG="${GIT_SHA}"
          echo "image-tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
          echo "build-date=${BUILD_DATE}" >> $GITHUB_OUTPUT
          echo "git-sha=${GIT_SHA}" >> $GITHUB_OUTPUT
          echo "Building image: ${{ secrets.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG}"

      - name: Log in to Azure Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_LOGIN_SERVER }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      # Set up Docker Buildx for efficient multi-platform builds
      # and layer caching via GitHub Actions cache
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push to ACR
        id: push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          target: production
          push: true
          tags: |
            ${{ secrets.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.image-tag }}
            ${{ secrets.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:main-latest
          labels: |
            org.opencontainers.image.created=${{ steps.meta.outputs.build-date }}
            org.opencontainers.image.revision=${{ steps.meta.outputs.git-sha }}
            org.opencontainers.image.source=${{ github.repository }}
          # GitHub Actions cache for Docker layers — dramatically speeds up rebuilds
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Verify image was pushed
        run: |
          echo "Image digest: ${{ steps.push.outputs.digest }}"
          echo "Image tag: ${{ steps.meta.outputs.image-tag }}"

  # ══════════════════════════════════════════════════════════════════════════
  # JOB 3: DEPLOY
  # Deploys to Azure Container Apps.
  # Uses environment protection for production gate.
  # Only runs after a successful build.
  # ══════════════════════════════════════════════════════════════════════════
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build
    timeout-minutes: 15

    # This references a GitHub Environment named "production"
    # Configure required reviewers in Settings → Environments → production
    # If a reviewer is required, the job pauses here waiting for approval
    environment:
      name: production
      url: https://pharmabot.yourdomain.com

    steps:
      - name: Log in to Azure
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy new image to Azure Container App
        run: |
          IMAGE="${{ secrets.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image-tag }}"
          echo "Deploying image: $IMAGE"

          az containerapp update \
            --name ${{ secrets.AZURE_CONTAINER_APP_NAME }} \
            --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
            --image $IMAGE

      - name: Wait for deployment to complete
        run: |
          # Poll until the Container App shows the new revision as active
          MAX_ATTEMPTS=30
          ATTEMPT=0
          TARGET_IMAGE="${{ secrets.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image-tag }}"

          while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
            CURRENT_IMAGE=$(az containerapp show \
              --name ${{ secrets.AZURE_CONTAINER_APP_NAME }} \
              --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
              --query "properties.template.containers[0].image" \
              --output tsv)

            if [ "$CURRENT_IMAGE" = "$TARGET_IMAGE" ]; then
              echo "Deployment successful. Running image: $CURRENT_IMAGE"
              break
            fi

            ATTEMPT=$((ATTEMPT + 1))
            echo "Attempt $ATTEMPT/$MAX_ATTEMPTS — waiting for deployment..."
            sleep 10
          done

          if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
            echo "Deployment timed out after ${MAX_ATTEMPTS} attempts"
            exit 1
          fi

      - name: Health check verification
        run: |
          APP_URL="https://pharmabot.yourdomain.com"
          MAX_ATTEMPTS=10
          ATTEMPT=0

          while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
            HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/health" || echo "000")

            if [ "$HTTP_CODE" = "200" ]; then
              echo "Health check passed (HTTP $HTTP_CODE)"
              exit 0
            fi

            ATTEMPT=$((ATTEMPT + 1))
            echo "Health check attempt $ATTEMPT/$MAX_ATTEMPTS — got HTTP $HTTP_CODE, retrying..."
            sleep 15
          done

          echo "Health check failed after $MAX_ATTEMPTS attempts"
          exit 1

      - name: Notify on failure
        if: failure()
        run: |
          echo "Deployment failed for commit ${{ github.sha }}"
          # In a real pipeline, post to Slack/Teams here

Caching pip Dependencies Between Runs

The actions/cache@v4 step is worth examining in detail:

YAML
- name: Cache pip dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

How it works:

  1. Before the pip install step, GitHub Actions checks whether a cache exists for the exact key ubuntu-latest-pip-<hash_of_requirements.txt>.
  2. If an exact hit is found, the cache is restored and pip install finishes in seconds (packages are already downloaded).
  3. If no exact hit, the restore-keys fallback is tried — ubuntu-latest-pip- matches any cache from this runner OS, even with a different hash. pip still needs to download new packages but can reuse unchanged ones.
  4. After the run, the cache is saved under the new key.

On a typical FastAPI + OpenAI project, this reduces install time from 90 seconds to under 10 seconds on cache hit.

Docker layer caching via cache-from: type=gha works similarly — GitHub Actions caches Docker build layers between runs. If only app/ changed, the pip install layer is served from cache.


Environment Protection Rules

GitHub Environments let you add gates before deployment jobs run. Configure them at Settings → Environments → production:

Required reviewers: One or more people must approve the deployment before it proceeds. The deploy job pauses and sends a review request. Useful for production deployments on critical services.

Wait timer: Add a mandatory delay (e.g., 5 minutes) between pipeline trigger and deployment. Gives time to catch obvious issues before they reach production.

Branch protections: Restrict which branches can deploy to this environment. Only main should be able to deploy to production.

Deployment history: Every deployment to the environment is recorded with the deploying user, the commit SHA, and the outcome. Essential for audit trails in regulated industries (healthcare, finance).


Secrets Management in the Pipeline

What Goes in GitHub Secrets

GitHub Secrets are encrypted at rest and masked in logs. Use them for:

  • Cloud provider credentials (AZURE_CREDENTIALS)
  • Registry passwords (ACR_PASSWORD)
  • API keys needed during build or deploy
  • Connection strings

Secrets are not available in the environment by default — you must explicitly map them into steps:

YAML
- name: Some step
  env:
    MY_SECRET: ${{ secrets.MY_SECRET }}
  run: echo "Secret is available as MY_SECRET env var"

What Secrets to Rotate

Service principal credentials and registry passwords should be rotated regularly. In GitHub:

  1. Generate new credentials in Azure
  2. Update the GitHub Secret
  3. The next pipeline run uses the new credentials
  4. Delete the old credentials from Azure

Using OIDC Instead of Long-Lived Credentials

For a more secure setup, use OIDC (OpenID Connect) federation — GitHub Actions gets a short-lived token from Azure AD without any stored password:

YAML
permissions:
  id-token: write  # Required for OIDC
  contents: read

steps:
  - name: Log in to Azure (OIDC  no password stored)
    uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Set up on the Azure side with:

Bash
az ad app federated-credential create \
  --id <APP_OBJECT_ID> \
  --parameters '{
    "name": "github-actions-pharmabot",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:your-org/pharmabot:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

OIDC is recommended over stored passwords because there's nothing to rotate, nothing to leak, and each token is valid for only the duration of the pipeline run.


Handling Failures

Test failure: The build and deploy jobs are skipped entirely (due to needs: test). The PR is blocked from merging if branch protection rules require passing checks.

Build failure: The deploy job is skipped. The failed image is not pushed to the registry.

Deploy failure: The health check step will fail after exhausting retries. The previous revision remains active in Azure Container Apps (it's still running until the new revision takes over). You can immediately roll back (covered in the rollback lesson).

Partial rollout failure: Azure Container Apps keeps both the old and new revisions alive during traffic shifting. If the new revision fails its health checks, traffic is not shifted and the old revision continues to serve 100% of traffic.


Pull Request Workflow

The workflow runs tests on every pull request but does not build or deploy:

YAML
on:
  push:
    branches:
      - main       # Full pipeline (test + build + deploy)
  pull_request:
    branches:
      - main       # Tests only (build and deploy are skipped by the if condition)

The if: github.ref == 'refs/heads/main' && github.event_name == 'push' condition on the build job ensures it only runs on actual pushes to main, not on pull requests targeting main.

This gives you fast feedback on PRs (tests only, runs in under 5 minutes) while ensuring main always triggers a full pipeline.


Summary

This pipeline enforces quality gates at every stage:

  1. Tests run on every PR and push — ruff, mypy, pytest with coverage threshold, all using mock Azure to avoid API costs
  2. Build only runs on main after tests pass — produces an immutable SHA-tagged image
  3. Deploy only runs after build succeeds — with optional human approval gate via GitHub Environments
  4. Health check verifies the deployment — before the pipeline marks success

Key practices:

  • concurrency: prevents race conditions from simultaneous deploys
  • actions/cache@v4 caches pip and Docker layers — cold build to deploy in under 10 minutes, warm in under 4
  • OIDC authentication eliminates stored credentials
  • SHA tags (never :latest) ensure every deployment is traceable and rollback-able

In the next lesson, we'll go deeper into test strategies — specifically how to write effective tests for LLM services using mocks, fixtures, and contract tests.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.