Skip to content

Refactor/resilience simplification #629

Refactor/resilience simplification

Refactor/resilience simplification #629

Workflow file for this run

name: Deploy
on:
push:
branches: [main]
paths:
- 'api/**'
- 'content/**'
- 'infra/**'
- 'scripts/**'
- 'docker-compose.yml'
- '.github/workflows/deploy.yml'
pull_request:
branches: [main]
workflow_dispatch:
inputs:
force_rebuild:
description: 'Force rebuild without cache (for security updates)'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
defaults:
run:
shell: bash
env:
AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
TF_VAR_environment: ${{ vars.AZURE_ENV_NAME }}
TF_VAR_location: ${{ vars.AZURE_LOCATION }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
jobs:
# -----------------------------------------------------------------------
# Changes — determine whether deploy jobs should run
# -----------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
outputs:
deploy: ${{ steps.filter.outputs.deploy }}
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 2
- name: Detect deploy-relevant changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
id: filter
with:
filters: |
deploy:
- 'api/**'
- 'content/**'
- 'infra/**'
- '.github/workflows/deploy.yml'
code:
- 'api/**'
- 'content/**'
# -----------------------------------------------------------------------
# Dependency Review — block PRs that introduce vulnerable dependencies
# -----------------------------------------------------------------------
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4
with:
fail-on-severity: moderate
# -----------------------------------------------------------------------
# CI — lint, type-check, test (runs on push AND PR)
# -----------------------------------------------------------------------
ci:
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: learntocloud
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/learntocloud
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version-file: "api/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.9.26"
enable-cache: true
- name: Install dependencies
run: uv sync --locked
- name: Ruff lint
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3
with:
src: "./api"
args: "check"
version: "0.8.6"
- name: Ruff format check
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3
with:
src: "./api"
args: "format --check"
version: "0.8.6"
- name: Type check (ty)
run: uv run ty check --python .venv/bin/python --exclude "scripts/" --exclude "tests/"
- name: Run tests with coverage
run: uv run pytest tests/ -v --tb=short --cov=. --cov-report=xml --cov-report=term-missing
# -----------------------------------------------------------------------
# Status gates — always run so required checks are reported on every PR,
# even when path filters cause the real jobs to be skipped.
# Point branch-protection at these instead of the underlying jobs.
# -----------------------------------------------------------------------
ci-status:
if: always()
needs: [ci]
runs-on: ubuntu-latest
steps:
- name: Check CI result
run: |
if [[ "${{ needs.ci.result }}" =~ ^(success|skipped)$ ]]; then
echo "CI passed or was skipped (no code changes)"
else
echo "CI failed: ${{ needs.ci.result }}"
exit 1
fi
dependency-review-status:
if: always()
needs: [dependency-review]
runs-on: ubuntu-latest
steps:
- name: Check dependency review result
run: |
if [[ "${{ needs.dependency-review.result }}" =~ ^(success|skipped)$ ]]; then
echo "Dependency review passed or was skipped (push event)"
else
echo "Dependency review failed: ${{ needs.dependency-review.result }}"
exit 1
fi
# -----------------------------------------------------------------------
# Terraform — plan + apply (push to main only)
# -----------------------------------------------------------------------
terraform:
if: always() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.deploy == 'true') && !contains(needs.*.result, 'failure')
needs: [ci, changes]
runs-on: ubuntu-latest
timeout-minutes: 20
env:
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_USE_OIDC: true
defaults:
run:
working-directory: infra
outputs:
api_container_app_name: ${{ steps.tf-outputs.outputs.api_container_app_name }}
resource_group_name: ${{ steps.tf-outputs.outputs.resource_group_name }}
container_registry_name: ${{ steps.tf-outputs.outputs.container_registry_name }}
container_registry_endpoint: ${{ steps.tf-outputs.outputs.container_registry_endpoint }}
api_url: ${{ steps.tf-outputs.outputs.api_url }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup Terraform
uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4
with:
terraform_version: "~1.5"
terraform_wrapper: false
- name: Azure CLI Login
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -out=tfplan -input=false -lock-timeout=120s
env:
TF_VAR_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
TF_VAR_github_client_id: ${{ secrets.TF_VAR_github_client_id }}
TF_VAR_github_client_secret: ${{ secrets.TF_VAR_github_client_secret }}
TF_VAR_session_secret_key: ${{ secrets.TF_VAR_session_secret_key }}
TF_VAR_labs_verification_secret: ${{ secrets.TF_VAR_labs_verification_secret }}
- name: Terraform Apply
run: terraform apply -auto-approve -lock-timeout=120s tfplan
- name: Get Terraform Outputs
id: tf-outputs
run: |
echo "api_container_app_name=ca-ltc-api-${{ vars.AZURE_ENV_NAME }}" >> "$GITHUB_OUTPUT"
echo "resource_group_name=$(terraform output -raw AZURE_RESOURCE_GROUP)" >> "$GITHUB_OUTPUT"
echo "container_registry_name=$(terraform output -raw AZURE_CONTAINER_REGISTRY_NAME)" >> "$GITHUB_OUTPUT"
echo "container_registry_endpoint=$(terraform output -raw AZURE_CONTAINER_REGISTRY_ENDPOINT)" >> "$GITHUB_OUTPUT"
echo "api_url=$(terraform output -raw apiUrl)" >> "$GITHUB_OUTPUT"
# -----------------------------------------------------------------------
# Deploy — build image, push, update container app (push to main only)
# -----------------------------------------------------------------------
deploy:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.deploy == 'true')
needs: [terraform]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Azure CLI Login
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Log in to ACR
run: az acr login --name ${{ needs.terraform.outputs.container_registry_name }}
- name: Build and Push API Image
run: |
CACHE_FLAGS=""
if [[ "${{ inputs.force_rebuild }}" == "true" ]]; then
CACHE_FLAGS="--no-cache --pull"
else
docker pull ${{ needs.terraform.outputs.container_registry_endpoint }}/api:latest || true
CACHE_FLAGS="--cache-from=${{ needs.terraform.outputs.container_registry_endpoint }}/api:latest"
fi
docker build -f api/Dockerfile \
$CACHE_FLAGS \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--label git-commit=${{ github.sha }} \
-t ${{ needs.terraform.outputs.container_registry_endpoint }}/api:${{ github.sha }} \
-t ${{ needs.terraform.outputs.container_registry_endpoint }}/api:latest .
docker push ${{ needs.terraform.outputs.container_registry_endpoint }}/api:${{ github.sha }}
docker push ${{ needs.terraform.outputs.container_registry_endpoint }}/api:latest
- name: Deploy to Container App
run: |
az containerapp update \
--name ${{ needs.terraform.outputs.api_container_app_name }} \
--resource-group ${{ needs.terraform.outputs.resource_group_name }} \
--container-name api \
--image ${{ needs.terraform.outputs.container_registry_endpoint }}/api:${{ github.sha }}
- name: Wait for readiness
run: |
READY_URL="${{ needs.terraform.outputs.api_url }}/ready"
echo "Waiting for API at: $READY_URL"
for i in {1..30}; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$READY_URL" || true)
if [[ "$code" == "200" ]]; then
echo "API is ready."
exit 0
fi
echo "Attempt $i: HTTP $code, retrying in 10s..."
sleep 10
done
echo "API did not become ready."
exit 1