Skip to main content

GitHub Actions Pipeline for Container Apps

You've built a LangGraph agent. It works locally, tests pass, and you've verified it against your staging data. Now you need a repeatable, automated pipeline that takes code from your editor to a live Container App β€” without manual Docker builds, without copying commands between terminals, and without secrets in plain text.

This guide walks you through building a complete CI/CD pipeline with GitHub Actions that:

  • Lints and type-checks your Python code on every push and pull request.
  • Runs your test suite with coverage reporting.
  • Builds a Docker image with uv for fast dependency resolution.
  • Pushes the image to Azure Container Registry with triple SHA tagging for full traceability.
  • Deploys to Azure Container Apps across dev and production environments using branch-based routing.
  • Uploads agent knowledge data (market rules) to Azure Blob Storage using OIDC auth.
  • Runs smoke tests against the live endpoint after deployment.

By the end of this article, you'll have a production-ready .github/workflows/agent-docker.yml file modeled after the real Walking Agent pipeline used in production.


Why CI/CD Matters for LLM Apps​

LLM-powered applications have deployment characteristics that make automation especially important:

ChallengeHow CI/CD Helps
Non-deterministic outputsAutomated smoke tests catch regressions before they reach users.
Prompt and tool changesEvery change goes through the same pipeline β€” no "it worked on my machine."
Model version driftPinned dependencies and reproducible builds ensure consistent behavior.
Multi-environment configEnvironment-specific secrets and settings are managed in GitHub, not copied by hand.
Cost controlAutomated pipelines prevent accidental deployments to production and keep dev/staging isolated.

Without a pipeline, deploying an agent is a sequence of terminal commands that someone has to remember, copy, and execute β€” and get right every time. A pipeline removes that friction and makes deployment a first-class part of your development workflow.


Pipeline Architecture​

The Walking Agent pipeline is a single job with conditional steps, rather than multiple interdependent jobs. This design keeps things simple: one job handles build, push, and deploy β€” with if: conditions controlling which environment receives the deployment.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ push β†’ main │────▢ Build + Push + Deploy to Dev
β”‚ push β†’ release/ │────▢ Build + Push + Deploy to Prod
β”‚ pull_request │────▢ Build + Push only (no deploy)
β”‚ workflow_dispatch│───▢ Build + Push + Deploy to Dev
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Path Filtering​

The pipeline only triggers when relevant files change. This avoids unnecessary builds when documentation, configuration, or unrelated code is modified:

on:
push:
branches:
- main
- release/**
paths:
- "agent/**"
- ".github/workflows/agent-docker.yml"

The paths filter restricts triggers to changes under the agent/ directory (where the agent code, Dockerfile, and knowledge data live) and to the workflow file itself. Without path filtering, every commit to the repository would trigger a Docker build β€” wasting minutes of runner time and incurring unnecessary costs.

Trigger Strategy​

The Walking Agent pipeline uses four trigger types, each with a distinct purpose:

TriggerWhat HappensWhy
pull_request β†’ mainBuild and push to ACR onlyVerify the image builds without deploying.
push β†’ mainBuild, push, and deploy to devAutomatic dev deployment on every merge.
push β†’ release/**Build, push, and deploy to prodRelease branches gate production deployments.
workflow_dispatchBuild, push, and deploy to devManual trigger for ad-hoc dev deployments.

The key design decision: pull requests never deploy. They only verify that the Docker image builds and pushes to ACR. This gives reviewers confidence the image is valid without risking a deployment to a live environment.

The Build and Push Flow​

Within the single build-and-push job, steps execute sequentially:

  1. Checkout the repository.
  2. Set up Docker Buildx for multi-platform support and advanced caching.
  3. Authenticate to Azure via OIDC federated identity.
  4. Log in to ACR using the dev credentials.
  5. Build and push the Docker image with GHA cache and triple tagging (latest, full SHA, short SHA).
  6. Upload agent knowledge data (market rules) to Azure Blob Storage.
  7. Deploy to dev (only on main or workflow_dispatch).
  8. Deploy to prod (only on release/** branches, using separate prod credentials).

Each deployment step has an if: condition, so the job runs once but only deploys to the environment that matches the current branch or trigger.


Setting Up GitHub Actions​

Creating the Workflow File​

All GitHub Actions workflows live in .github/workflows/ at the root of your repository. Create the file:

mkdir -p .github/workflows
touch .github/workflows/agent-docker.yml

The workflow file is standard YAML. GitHub parses it, validates the syntax, and executes the defined jobs on its hosted runners.

Configuring Azure Credentials with Federated Identity​

Our team uses OpenID Connect (OIDC) federated identity for Azure authentication β€” not service principal secrets. This means:

  • No long-lived credentials to rotate.
  • No secret values stored in GitHub that could be leaked.
  • Token validity scoped to the workflow run duration (1 hour).
  • Least-privilege access via Azure role assignments.

Step 1 β€” Create a Service Principal​

# Create the service principal with Contributor role on your resource group
az ad sp create-for-rbac \
--name "gha-walking-agent" \
--role Contributor \
--scopes /subscriptions/<SUBSCRIPTION_ID>/resourceGroups/rg-cr-retail-assist-dev \
--json-auth \
--output json

Save the appId from the output β€” this is your client ID. The Walking Agent pipeline uses separate service principals for dev and prod, each scoped to its own resource group.

Step 2 β€” Configure the Federated Credential​

# Get your Azure tenant ID
TENANT_ID=$(az account show --query tenantId --output tsv)

# Create the federated identity credential for the main branch
az rest \
--method PUT \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/<CLIENT_ID>/federatedIdentityCredentials" \
--body "{\"name\":\"github-actions-walking-agent\",\"issuer\":\"https://token.actions.githubusercontent.com\",\"subject\":\"repo:<ORG>/<REPO>:ref:refs/heads/main\",\"audiences\":[\"api://AzureADTokenExchange\"]}"

Replace <CLIENT_ID>, <ORG>, and <REPO> with your values. The subject field restricts this credential to the main branch β€” you can add additional subjects for release/** branches and pull requests.

Step 3 β€” Add GitHub Secrets​

The Walking Agent pipeline uses environment-prefixed secrets to keep dev and prod credentials separate. In your repository, navigate to Settings β†’ Secrets and variables β†’ Actions and create these secrets:

Secret NameValuePurpose
AGENT_ACR_LOGIN_SERVERe.g. crcrretailassistdev.azurecr.ioDev ACR login server
AGENT_ARM_CLIENT_IDDev service principal client IDOIDC auth for dev
AGENT_ARM_TENANT_IDAzure AD tenant ID (dev)OIDC auth for dev
AGENT_ARM_SUBSCRIPTION_IDAzure subscription ID (dev)OIDC auth for dev
AGENT_STORAGE_ACCOUNT_NAMEStorage account nameBlob storage for agent knowledge data
AGENT_ACR_LOGIN_SERVER_PRODProd ACR login serverProd ACR (placeholder until prod exists)
AGENT_ARM_CLIENT_ID_PRODProd service principal client IDOIDC auth for prod
AGENT_ARM_TENANT_ID_PRODAzure AD tenant ID (prod)OIDC auth for prod
AGENT_ARM_SUBSCRIPTION_ID_PRODAzure subscription ID (prod)OIDC auth for prod

The _PROD suffix convention makes it immediately clear which secrets belong to which environment. The pipeline references them directly in the YAML β€” no environment-scoped secrets are needed because branch conditions (if:) already route to the right credentials.

Never Store Service Principal Secrets

Do not use the creds parameter with a JSON blob containing a client secret. That approach stores long-lived credentials in GitHub Secrets, which violates our security policy. OIDC federated identity is the only approved method.

Trigger Configuration​

The Walking Agent pipeline responds to four trigger types, with path filtering to avoid unnecessary builds:

on:
push:
branches:
- main
- release/**
paths:
- "agent/**"
- ".github/workflows/agent-docker.yml"
pull_request:
branches:
- main
paths:
- "agent/**"
- ".github/workflows/agent-docker.yml"
workflow_dispatch:
  • Push to main β€” builds the image, pushes to ACR, and deploys to dev.
  • Push to release/** branches β€” builds the image, pushes to ACR, and deploys to prod.
  • Pull requests to main β€” builds and pushes to ACR only (no deployment).
  • Manual trigger (workflow_dispatch) β€” builds, pushes, and deploys to dev.

The paths filter ensures the pipeline only runs when agent code or the workflow file changes, not when unrelated files are modified.


The CI Stage​

The Walking Agent pipeline (agent-docker.yml) focuses on the build, push, and deploy workflow. CI β€” linting, type-checking, and testing β€” typically runs in a separate workflow file that executes first. If your CI fails, the build pipeline never starts.

A typical CI workflow for the agent code looks like this:

# .github/workflows/ci.yml
name: CI β€” Agent Code

on:
push:
branches:
- main
paths:
- "agent/**"
pull_request:
branches:
- main
paths:
- "agent/**"

jobs:
ci:
name: Lint, Type Check, Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: agent

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

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: 'agent/.python-version'

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: 'latest'
enable-cache: true
cache-dependency-gopath: agent/uv.lock

- name: Install dependencies
run: uv sync --frozen

- name: Lint with ruff
run: uv run ruff check .

- name: Check formatting with ruff
run: uv run ruff format --check .

- name: Type check with ty
run: uv run ty check

- name: Run tests with pytest
run: |
uv run pytest \
-m "not llm and not integration" \
--cov=src \
--cov-report=xml \
--cov-report=term-missing \
-v
env:
USE_MOCK_LLM: "true"

The working-directory: agent directive ensures all commands run inside the agent/ directory, matching the path-filtered structure of the repository.

How uv Works in GitHub Actions​

The astral-sh/setup-uv action installs uv and configures dependency caching. Here is what happens:

  1. enable-cache: true β€” GitHub Actions caches the uv dependency cache between runs.
  2. cache-dependency-gopath: uv.lock β€” The cache key is derived from uv.lock, so it invalidates only when dependencies change.
  3. uv sync --frozen β€” Installs the exact versions from the lockfile. The --frozen flag fails the build if uv.lock is out of date.

This combination gives you fast CI runs: the first build after a dependency change takes ~15 seconds, and subsequent runs (no dependency changes) complete in ~3 seconds.

Test Configuration for LLM Apps​

Your pytest configuration should handle LLM-specific concerns:

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-markers",
"--strict-config",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests that require external services",
"llm: marks tests that call real LLM APIs (use mocks in CI)",
]

In CI, skip tests that call real LLM APIs:

- name: Run tests with pytest
run: |
uv run pytest \
-m "not llm and not integration" \
--cov=src \
--cov-report=xml \
-v
env:
# Use mock LLM in CI
USE_MOCK_LLM: "true"

Your test suite should include mocked LLM responses:

# tests/test_agent.py
import pytest
from unittest.mock import patch, MagicMock

from src.agent import create_agent


@pytest.fixture
def mock_llm():
"""Mock LLM that returns deterministic responses."""
mock = MagicMock()
mock.invoke.return_value.content = "This is a test response from the agent."
mock.invoke.return_value.tool_calls = []
return mock


def test_agent_returns_response(mock_llm):
"""Agent returns a response without calling real LLM."""
with patch("src.agent.init_chat_model", return_value=mock_llm):
agent = create_agent()
result = agent.invoke({"messages": [{"role": "user", "content": "Hello"}]})

assert result is not None
assert "messages" in result
assert len(result["messages"]) > 0

The CD Stage​

The Walking Agent pipeline handles build, push, and deploy in a single job called build-and-push. Deployment to specific environments is controlled by if: conditions on individual steps, rather than separate jobs. This keeps the pipeline simple and avoids the overhead of job-level dependencies.

Complete Build and Push Job YAML​

jobs:
build-and-push:
name: Build & Push
runs-on: ubuntu-latest

permissions:
contents: read
id-token: write

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

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }}

- name: Log in to Azure Container Registry
run: az acr login --name "${{ secrets.AGENT_ACR_LOGIN_SERVER }}"

- name: Build and push image
run: |
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest" \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" \
--push \
./agent

- name: Compute short SHA
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"

- name: Push short-SHA tag
run: |
docker buildx imagetools create \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}" \
"${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest"

- name: Upload market-uk.md to blob storage
run: |
az storage blob upload \
--account-name "${{ secrets.AGENT_STORAGE_ACCOUNT_NAME }}" \
--container-name "walking-agent" \
--name "uk/rules/market-uk.md" \
--file "agent/rules/market-uk.md" \
--auth-mode login \
--overwrite true

- name: Azure login – dev (OIDC)
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }}

- name: Deploy to dev
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
run: |
az containerapp update \
--name ca-cr-retail-assist-dev \
--resource-group rg-cr-retail-assist-dev \
--subscription ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }} \
--image "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

- name: Azure login – prod (OIDC)
if: startsWith(github.ref, 'refs/heads/release/')
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID_PROD }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID_PROD }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID_PROD }}
allow-no-subscriptions: true

- name: Log in to prod ACR
if: startsWith(github.ref, 'refs/heads/release/')
run: az acr login --name "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}"

- name: Push image to prod ACR
if: startsWith(github.ref, 'refs/heads/release/')
run: |
docker buildx imagetools create \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}" \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:latest" \
"${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

- name: Deploy to prod
if: startsWith(github.ref, 'refs/heads/release/')
run: |
az containerapp update \
--name ca-cr-retail-assist-prod \
--resource-group rg-cr-retail-assist-prod \
--subscription ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID_PROD }} \
--image "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

Key Details Explained​

OIDC Authentication with azure/login@v2​

The azure/login@v2 action authenticates using the federated identity credential configured earlier. The permissions: id-token: write block is required β€” it allows GitHub to request an OIDC token from Azure AD.

The Walking Agent pipeline authenticates separately for dev and prod using different credentials. The dev login uses AGENT_ARM_* secrets, while the prod login uses AGENT_ARM_*_PROD secrets. The prod login also includes allow-no-subscriptions: true, which is needed when the federated credential is scoped at the tenant level rather than the subscription level.

Docker Buildx with GHA Cache​

The pipeline uses docker buildx build directly rather than the docker/build-push-action wrapper. This gives more control over the build process:

  • --platform linux/amd64 β€” Ensures consistent builds regardless of runner architecture.
  • --provenance=false β€” Disables SBOM provenance metadata (not needed for internal deployments).
  • --cache-from type=gha β€” Pulls build cache from GitHub Actions cache.
  • --cache-to type=gha,mode=max β€” Writes all cache layers back to GitHub Actions cache.

The GHA cache persists between workflow runs. On the first build, all layers are cold. On subsequent builds, only changed layers are rebuilt β€” typically reducing build time from 2+ minutes to ~30 seconds.

Triple SHA Tagging​

The Walking Agent pipeline applies three tags to every image:

TagApplied ByPurpose
latestdocker buildx build --tagConvenient for manual inspection and default pulls.
Full SHA (e.g., a1b2c3d4e5f6...)docker buildx build --tagFull traceability β€” maps image to exact commit.
Short SHA (e.g., a1b2c3d)docker buildx imagetools createHuman-readable reference in Container App configs and logs.

The short SHA tag is applied via docker buildx imagetools create after the initial build. This is a separate step because docker buildx build cannot create tags referencing its own output β€” imagetools create creates a new tag that points to the same image manifest.

The short SHA is computed using bash substring slicing: ${GITHUB_SHA::7} extracts the first 7 characters of the full SHA.

Container Apps Deployment​

The az containerapp update command updates the Container App to use the new image. This creates a new revision β€” an immutable snapshot of the app configuration. The previous revision continues running until the new one is healthy.

The pipeline deploys using the short SHA tag (--image ...:a1b2c3d), making it easy to identify which commit is running by looking at the Container App revision details.

Branch-Based Step Conditions​

Each deployment step has an if: condition that controls when it runs:

  • Dev deployment β€” if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' Runs on pushes to main or when manually triggered. Pull requests to main do not deploy to dev.

  • Prod deployment β€” if: startsWith(github.ref, 'refs/heads/release/') Runs only when pushing to a release/** branch. This is the gate for production β€” no code reaches prod without an explicit release branch.

This pattern means the same job handles both environments, but the if: conditions ensure dev and prod are never deployed simultaneously from a single push.


Environment Strategy​

The Walking Agent pipeline uses branch-based routing to determine which environment receives a deployment. A single job handles both dev and prod, with if: conditions on individual steps controlling the flow.

Branch-to-Environment Mapping​

BranchEnvironmentWhat Happens
mainDevBuild, push to ACR, deploy to ca-cr-retail-assist-dev
release/**ProdBuild, push to ACR, cross-push to prod ACR, deploy to ca-cr-retail-assist-prod
Pull request to mainNoneBuild and push to ACR only (no deployment)
workflow_dispatchDevBuild, push to ACR, deploy to ca-cr-retail-assist-dev

The key design principle: pull requests never deploy. They verify the Docker image builds and pushes to ACR, giving reviewers confidence without risking a live environment.

Dev Environment​

The dev deployment uses the AGENT_ARM_* secrets and deploys to ca-cr-retail-assist-dev in the rg-cr-retail-assist-dev resource group. It triggers automatically on every push to main and can also be triggered manually via workflow_dispatch.

- name: Deploy to dev
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
run: |
az containerapp update \
--name ca-cr-retail-assist-dev \
--resource-group rg-cr-retail-assist-dev \
--subscription ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }} \
--image "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

The if: condition checks both github.ref (for push events) and github.event_name (for manual triggers). Without checking github.event_name, a workflow_dispatch would not deploy because its github.ref might not match refs/heads/main.

Prod Environment​

The prod deployment uses separate _PROD suffixed secrets and deploys to ca-cr-retail-assist-prod in the rg-cr-retail-assist-prod resource group. It only triggers on pushes to release/** branches.

- name: Azure login – prod (OIDC)
if: startsWith(github.ref, 'refs/heads/release/')
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID_PROD }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID_PROD }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID_PROD }}
allow-no-subscriptions: true

The allow-no-subscriptions: true flag is included in the prod login step. This is necessary when the federated credential is scoped at the tenant level rather than the subscription level β€” it allows the login to succeed even if the identity has no subscription-level permissions initially.

For prod, the pipeline also copies the image from the dev ACR to the prod ACR using docker buildx imagetools create. This cross-registry push ensures the prod Container App pulls from its own ACR, maintaining separation between environments:

- name: Push image to prod ACR
if: startsWith(github.ref, 'refs/heads/release/')
run: |
docker buildx imagetools create \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}" \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:latest" \
"${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

Environment-Specific Secrets​

The Walking Agent pipeline uses a _PROD suffix convention to separate dev and prod credentials:

SecretDev ValueProd Value
AGENT_ARM_CLIENT_IDDev SP client IDβ€”
AGENT_ARM_CLIENT_ID_PRODβ€”Prod SP client ID
AGENT_ARM_TENANT_IDDev tenant IDβ€”
AGENT_ARM_TENANT_ID_PRODβ€”Prod tenant ID
AGENT_ARM_SUBSCRIPTION_IDDev subscription IDβ€”
AGENT_ARM_SUBSCRIPTION_ID_PRODβ€”Prod subscription ID
AGENT_ACR_LOGIN_SERVERDev ACRβ€”
AGENT_ACR_LOGIN_SERVER_PRODβ€”Prod ACR

The _PROD suffix makes it immediately clear which secrets belong to which environment. The pipeline references them directly in the YAML β€” no GitHub environment-scoped secrets are needed because branch conditions already route to the right credentials.

GitHub Environments (Optional)​

You can still configure GitHub Environments for additional protection:

Prod environment:

  • Required reviewers: at least 2 team members.
  • Wait timer: 5 minutes (prevents accidental immediate deployment).
  • Deployment branch rules: only release/**.

To use GitHub Environments, add environment: prod to the job definition and configure the environment in Settings β†’ Environments. The if: conditions in the Walking Agent pipeline already provide branch-based gating, so GitHub Environments are an additional layer, not a replacement.


Uploading Agent Knowledge Data to Blob Storage​

LLM agents often depend on external knowledge data β€” market rules, regulatory documents, product catalogs β€” that is not baked into the Docker image. The Walking Agent pipeline uploads this data to Azure Blob Storage as part of the same build job, ensuring the agent's knowledge is always in sync with the deployed image.

Why Blob Storage Instead of Baking Data into the Image​

ApproachProsCons
Baked into imageSimple, self-containedLarge image size, slow rebuilds when data changes, no hot-reload
Blob StorageSmall Docker image, data updates independently, agent fetches at runtimeRequires storage permissions, network access from the Container App

For the Walking Agent, market rules (uk/rules/market-uk.md) change independently of code. Uploading to blob storage means a rule update doesn't require a full Docker rebuild.

The Upload Step​

- name: Upload market-uk.md to blob storage
run: |
az storage blob upload \
--account-name "${{ secrets.AGENT_STORAGE_ACCOUNT_NAME }}" \
--container-name "walking-agent" \
--name "uk/rules/market-uk.md" \
--file "agent/rules/market-uk.md" \
--auth-mode login \
--overwrite true

Key details:

  • --auth-mode login β€” Uses the current Azure CLI login (OIDC) instead of a storage account key. The OIDC identity needs Storage Blob Data Contributor role on the walking-agent container.
  • --overwrite true β€” Ensures each pipeline run updates the blob, even if the file already exists.
  • Blob path β€” The --name parameter sets the blob path within the container. The Walking Agent reads from uk/rules/market-uk.md at runtime.

Required Permissions​

The OIDC managed identity used by the pipeline needs the Storage Blob Data Contributor role assigned on the blob container (not the storage account). This follows the principle of least privilege:

az role assignment create \
--assignee ${{ secrets.AGENT_ARM_CLIENT_ID }} \
--role "Storage Blob Data Contributor" \
--scope /subscriptions/<SUB>/resourceGroups/<RG>/providers/Microsoft.Storage/storageAccounts/<ACCOUNT>/blobServices/default/containers/walking-agent

Adapting for Your Agent​

If your agent uses knowledge data, add a similar upload step. Common patterns:

  • Multiple files β€” Use az storage blob upload-batch to upload an entire directory.
  • Conditional upload β€” Add an if: condition with path filtering: if: always() && contains(github.event.head_commit.message, '[data]').
  • Secrets in knowledge data β€” Store sensitive configuration in GitHub Secrets or Azure Key Vault, not in blob storage.

Complete Pipeline Example​

Here is the full, annotated Walking Agent pipeline. Copy it into .github/workflows/agent-docker.yml and adapt the secret names and resource references to match your Azure setup.

# .github/workflows/agent-docker.yml
# ---------------------------------------------------------------------------
# Build and push the Walking Agent Docker image to Azure Container Registry,
# then deploy to the appropriate environment based on the branch.
#
# Triggers
# ────────
# pull_request β†’ main : build + push to ACR (no deploy)
# push β†’ main : build + push + deploy to dev (automatic)
# push β†’ release/** : build + push + deploy to prod (automatic)
# workflow_dispatch : build + push + deploy to dev (manual)
#
# Required GitHub Secrets
# ───────────────────────
# AGENT_ACR_LOGIN_SERVER – e.g. crcrretailassistdev.azurecr.io
# AGENT_STORAGE_ACCOUNT_NAME – Storage account name (no https://, no .blob.core.windows.net)
# Container is "walking-agent"; OIDC identity needs
# "Storage Blob Data Contributor" on that container.
#
# Dev deployment secrets
# ───────────────────────
# AGENT_ARM_CLIENT_ID – Managed identity client ID (OIDC)
# AGENT_ARM_TENANT_ID – Azure AD tenant ID
# AGENT_ARM_SUBSCRIPTION_ID – Azure subscription ID
#
# Prod deployment secrets (placeholders – fill in once prod subscription exists)
# ───────────────────────────────────────────────────────────────────────────────
# AGENT_ACR_LOGIN_SERVER_PROD – ACR login server (prod)
# AGENT_ARM_CLIENT_ID_PROD – Managed identity client ID (OIDC)
# AGENT_ARM_TENANT_ID_PROD – Azure AD tenant ID (prod)
# AGENT_ARM_SUBSCRIPTION_ID_PROD – Azure subscription ID (prod)
# ---------------------------------------------------------------------------

name: Build and Push Agent Image

env:
IMAGE_NAME: walking-agent
ARM_USE_OIDC: "true"

on:
# Push to main or release branches, but only if agent code changed
push:
branches:
- main
- release/**
paths:
- "agent/**"
- ".github/workflows/agent-docker.yml"

# Pull requests only build and push (no deployment)
pull_request:
branches:
- main
paths:
- "agent/**"
- ".github/workflows/agent-docker.yml"

# Manual trigger for ad-hoc dev deployments
workflow_dispatch:

jobs:
# ──────────────────────────────────────────────────────────
# Single job: build, push, and conditionally deploy
# ──────────────────────────────────────────────────────────
build-and-push:
name: Build & Push
runs-on: ubuntu-latest

# OIDC authentication requires id-token write permission
permissions:
contents: read
id-token: write

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

# ── Docker Buildx setup ────────────────────────────────
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# ── Azure login for dev environment (OIDC) ─────────────
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }}

# ── ACR login ──────────────────────────────────────────
- name: Log in to Azure Container Registry
run: az acr login --name "${{ secrets.AGENT_ACR_LOGIN_SERVER }}"

# ── Build and push with GHA cache and dual tagging ─────
# Tags: latest + full SHA
- name: Build and push image
run: |
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest" \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" \
--push \
./agent

# ── Compute short SHA for human-readable tagging ───────
- name: Compute short SHA
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"

# ── Create short-SHA tag via imagetools ────────────────
- name: Push short-SHA tag
run: |
docker buildx imagetools create \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}" \
"${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest"

# ── Upload UK market rules to blob storage ─────────────
# Blob path: uk/rules/market-uk.md (container: walking-agent)
# The OIDC identity needs "Storage Blob Data Contributor" on the container.
- name: Upload market-uk.md to blob storage
run: |
az storage blob upload \
--account-name "${{ secrets.AGENT_STORAGE_ACCOUNT_NAME }}" \
--container-name "walking-agent" \
--name "uk/rules/market-uk.md" \
--file "agent/rules/market-uk.md" \
--auth-mode login \
--overwrite true

# ── Deploy to dev (main branch + workflow_dispatch) ────
# Requires AcrPull on the ACR and Contributor on
# ca-cr-retail-assist-dev to be assigned to the managed
# identity before this step will succeed.
- name: Azure login – dev (OIDC)
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }}

- name: Deploy to dev
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
run: |
az containerapp update \
--name ca-cr-retail-assist-dev \
--resource-group rg-cr-retail-assist-dev \
--subscription ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID }} \
--image "${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

# ── Deploy to prod (release/** branches) ───────────────
# Uses separate prod credentials and cross-pushes the
# image to the prod ACR.
- name: Azure login – prod (OIDC)
if: startsWith(github.ref, 'refs/heads/release/')
uses: azure/login@v2
with:
client-id: ${{ secrets.AGENT_ARM_CLIENT_ID_PROD }}
tenant-id: ${{ secrets.AGENT_ARM_TENANT_ID_PROD }}
subscription-id: ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID_PROD }}
allow-no-subscriptions: true

- name: Log in to prod ACR
if: startsWith(github.ref, 'refs/heads/release/')
run: az acr login --name "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}"

- name: Push image to prod ACR
if: startsWith(github.ref, 'refs/heads/release/')
run: |
docker buildx imagetools create \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}" \
--tag "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:latest" \
"${{ secrets.AGENT_ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

- name: Deploy to prod
if: startsWith(github.ref, 'refs/heads/release/')
run: |
az containerapp update \
--name ca-cr-retail-assist-prod \
--resource-group rg-cr-retail-assist-prod \
--subscription ${{ secrets.AGENT_ARM_SUBSCRIPTION_ID_PROD }} \
--image "${{ secrets.AGENT_ACR_LOGIN_SERVER_PROD }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }}"

Pipeline Features​

FeatureHow It Works
Path filteringpaths: agent/** ensures builds only run when agent code or the workflow file changes.
Triple SHA taggingEvery image gets latest, full SHA, and short SHA tags via docker buildx + imagetools create.
GHA cache--cache-from type=gha / --cache-to type=gha,mode=max persists Docker build layers between runs.
Blob storage uploadAgent knowledge data (market rules) is uploaded to Azure Blob Storage using OIDC auth.
Branch-based routingmain deploys to dev, release/** deploys to prod, PRs build only.
Environment-specific secretsDev uses AGENT_ARM_*, prod uses AGENT_ARM_*_PROD β€” no overlap.
OIDC throughoutEvery Azure interaction uses federated identity, no long-lived credentials.
Cross-registry pushProd deployments copy the image from dev ACR to prod ACR via imagetools create.

Adapting the Pipeline​

To adapt this pipeline for your own agent:

  1. Replace secret names β€” Change AGENT_* prefixes to match your naming convention.
  2. Update resource names β€” Replace ca-cr-retail-assist-* and rg-cr-retail-assist-* with your Container App and resource group names.
  3. Adjust path filters β€” If your agent code lives in a different directory, update the paths entries.
  4. Add or remove blob uploads β€” If your agent doesn't use external knowledge data, remove the blob upload step.
  5. Add smoke tests β€” The Walking Agent pipeline does not include smoke tests. Add them after the deploy steps using az containerapp show to fetch the FQDN, then curl the health and agent endpoints.

Adding a Status Badge​

Add this to your README.md to show pipeline status:

[![Build and Push Agent Image](https://github.com/<org>/<repo>/actions/workflows/agent-docker.yml/badge.svg)](https://github.com/<org>/<repo>/actions/workflows/agent-docker.yml)

Next Steps​

You now have a complete CI/CD pipeline for deploying LangGraph agents to Azure Container Apps. Here is where to go next:

Pipeline Optimization

If your builds are taking longer than 2 minutes, check the Docker Buildx cache hit rate in the workflow logs. A cold cache on every run usually means your uv.lock file is changing frequently or your Dockerfile does not optimize layer ordering. See the Best Practices guide for detailed caching strategies.