Refactor/resilience simplification #629
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |