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
uvfor 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:
| Challenge | How CI/CD Helps |
|---|---|
| Non-deterministic outputs | Automated smoke tests catch regressions before they reach users. |
| Prompt and tool changes | Every change goes through the same pipeline β no "it worked on my machine." |
| Model version drift | Pinned dependencies and reproducible builds ensure consistent behavior. |
| Multi-environment config | Environment-specific secrets and settings are managed in GitHub, not copied by hand. |
| Cost control | Automated 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:
| Trigger | What Happens | Why |
|---|---|---|
pull_request β main | Build and push to ACR only | Verify the image builds without deploying. |
push β main | Build, push, and deploy to dev | Automatic dev deployment on every merge. |
push β release/** | Build, push, and deploy to prod | Release branches gate production deployments. |
workflow_dispatch | Build, push, and deploy to dev | Manual 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:
- Checkout the repository.
- Set up Docker Buildx for multi-platform support and advanced caching.
- Authenticate to Azure via OIDC federated identity.
- Log in to ACR using the dev credentials.
- Build and push the Docker image with GHA cache and triple tagging (
latest, full SHA, short SHA). - Upload agent knowledge data (market rules) to Azure Blob Storage.
- Deploy to dev (only on
mainorworkflow_dispatch). - 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 Name | Value | Purpose |
|---|---|---|
AGENT_ACR_LOGIN_SERVER | e.g. crcrretailassistdev.azurecr.io | Dev ACR login server |
AGENT_ARM_CLIENT_ID | Dev service principal client ID | OIDC auth for dev |
AGENT_ARM_TENANT_ID | Azure AD tenant ID (dev) | OIDC auth for dev |
AGENT_ARM_SUBSCRIPTION_ID | Azure subscription ID (dev) | OIDC auth for dev |
AGENT_STORAGE_ACCOUNT_NAME | Storage account name | Blob storage for agent knowledge data |
AGENT_ACR_LOGIN_SERVER_PROD | Prod ACR login server | Prod ACR (placeholder until prod exists) |
AGENT_ARM_CLIENT_ID_PROD | Prod service principal client ID | OIDC auth for prod |
AGENT_ARM_TENANT_ID_PROD | Azure AD tenant ID (prod) | OIDC auth for prod |
AGENT_ARM_SUBSCRIPTION_ID_PROD | Azure 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.
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:
enable-cache: trueβ GitHub Actions caches theuvdependency cache between runs.cache-dependency-gopath: uv.lockβ The cache key is derived fromuv.lock, so it invalidates only when dependencies change.uv sync --frozenβ Installs the exact versions from the lockfile. The--frozenflag fails the build ifuv.lockis 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:
| Tag | Applied By | Purpose |
|---|---|---|
latest | docker buildx build --tag | Convenient for manual inspection and default pulls. |
Full SHA (e.g., a1b2c3d4e5f6...) | docker buildx build --tag | Full traceability β maps image to exact commit. |
Short SHA (e.g., a1b2c3d) | docker buildx imagetools create | Human-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 tomainor when manually triggered. Pull requests tomaindo not deploy to dev. -
Prod deployment β
if: startsWith(github.ref, 'refs/heads/release/')Runs only when pushing to arelease/**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β
| Branch | Environment | What Happens |
|---|---|---|
main | Dev | Build, push to ACR, deploy to ca-cr-retail-assist-dev |
release/** | Prod | Build, push to ACR, cross-push to prod ACR, deploy to ca-cr-retail-assist-prod |
Pull request to main | None | Build and push to ACR only (no deployment) |
workflow_dispatch | Dev | Build, 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:
| Secret | Dev Value | Prod Value |
|---|---|---|
AGENT_ARM_CLIENT_ID | Dev SP client ID | β |
AGENT_ARM_CLIENT_ID_PROD | β | Prod SP client ID |
AGENT_ARM_TENANT_ID | Dev tenant ID | β |
AGENT_ARM_TENANT_ID_PROD | β | Prod tenant ID |
AGENT_ARM_SUBSCRIPTION_ID | Dev subscription ID | β |
AGENT_ARM_SUBSCRIPTION_ID_PROD | β | Prod subscription ID |
AGENT_ACR_LOGIN_SERVER | Dev 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β
| Approach | Pros | Cons |
|---|---|---|
| Baked into image | Simple, self-contained | Large image size, slow rebuilds when data changes, no hot-reload |
| Blob Storage | Small Docker image, data updates independently, agent fetches at runtime | Requires 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 needsStorage Blob Data Contributorrole on thewalking-agentcontainer.--overwrite trueβ Ensures each pipeline run updates the blob, even if the file already exists.- Blob path β The
--nameparameter sets the blob path within the container. The Walking Agent reads fromuk/rules/market-uk.mdat 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-batchto 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β
| Feature | How It Works |
|---|---|
| Path filtering | paths: agent/** ensures builds only run when agent code or the workflow file changes. |
| Triple SHA tagging | Every 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 upload | Agent knowledge data (market rules) is uploaded to Azure Blob Storage using OIDC auth. |
| Branch-based routing | main deploys to dev, release/** deploys to prod, PRs build only. |
| Environment-specific secrets | Dev uses AGENT_ARM_*, prod uses AGENT_ARM_*_PROD β no overlap. |
| OIDC throughout | Every Azure interaction uses federated identity, no long-lived credentials. |
| Cross-registry push | Prod deployments copy the image from dev ACR to prod ACR via imagetools create. |
Adapting the Pipelineβ
To adapt this pipeline for your own agent:
- Replace secret names β Change
AGENT_*prefixes to match your naming convention. - Update resource names β Replace
ca-cr-retail-assist-*andrg-cr-retail-assist-*with your Container App and resource group names. - Adjust path filters β If your agent code lives in a different directory, update the
pathsentries. - Add or remove blob uploads β If your agent doesn't use external knowledge data, remove the blob upload step.
- Add smoke tests β The Walking Agent pipeline does not include smoke tests. Add them after the deploy steps using
az containerapp showto fetch the FQDN, thencurlthe health and agent endpoints.
Adding a Status Badgeβ
Add this to your README.md to show pipeline status:
[](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:
- CI/CD Best Practices β advanced patterns for pipeline optimization, secret rotation, canary deployments, and cost monitoring.
- Azure Container Apps Getting Started β the full guide to containerizing and deploying agents, including managed identity and service connectivity.
- Python Tooling β our team's standards for
uv,ruff,ty, andpytestin CI contexts. - LangGraph Best Practices β production-grade agent design patterns, including error handling and testing strategies.
- GitHub Actions Documentation β official reference for workflow syntax, actions, and runner configuration.
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.