From 399f1752f39525e745c978a2a289933242d4dbd4 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 12 May 2026 09:29:30 -0700 Subject: [PATCH 1/2] ci: simplify deploy jobs now that Terraform manages Container App config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terraform now owns: - UAMI identity assignment - ACR registry config (pull via managed identity) - Key Vault secret refs - Container env vars So CI no longer needs to re-configure these on every deploy. Each deploy job is now just: push image → az containerapp update. Production now uses az acr import to copy from dev ACR (server-side, no artifact re-download). Deploys by digest for immutability. Added production hardening: - Deploy by image digest (@sha256:...) instead of mutable tag - Post-deploy image verification (catches silent TF rollback) - Smoke test (curl /health) - Git deploy tag (deployed/prod/) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build-Test-And-Deploy.yml | 173 ++++++++------------ 1 file changed, 72 insertions(+), 101 deletions(-) diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index 82088c4c..ff425f8f 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -63,7 +63,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 -# Build but no push with a PR + # Build but no push with a PR - name: Docker build (no push) if: github.event_name == 'pull_request' || github.event_name == 'merge_group' uses: docker/build-push-action@v7 @@ -74,11 +74,12 @@ jobs: context: . build-args: ACCESS_TO_NUGET_FEED=false + # Only build for dev registry — prod gets the image via az acr import in deploy-production - name: Build Container Image if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request' uses: docker/build-push-action@v7 with: - tags: ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:latest,${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:latest + tags: ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:latest file: ./EssentialCSharp.Web/Dockerfile context: . secrets: | @@ -86,6 +87,7 @@ jobs: outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar cache-from: type=gha cache-to: type=gha,mode=max + - name: Upload artifact uses: actions/upload-artifact@v7 with: @@ -101,6 +103,9 @@ jobs: cancel-in-progress: false environment: name: "Development" + permissions: + id-token: write + contents: read steps: - name: Azure Login @@ -126,55 +131,20 @@ jobs: REGISTRY="${{ vars.DEVCONTAINER_REGISTRY }}" az acr login --name "${REGISTRY%.azurecr.io}" - - name: Push Image to Container Registry + - name: Push Image to Dev Container Registry run: docker push --all-tags ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb - - name: Configure Container App Identity and Registry - uses: azure/CLI@v3 + - name: Deploy to Container App env: CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} - CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }} - with: - inlineScript: | - # Container app must already exist; use az containerapp up manually to bootstrap if needed - az extension add --name containerapp --upgrade - az containerapp identity assign --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --user-assigned ${{ secrets.WEB_UAMI_RESOURCE_ID }} - az containerapp registry set --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --server $CONTAINER_REGISTRY --identity ${{ secrets.WEB_UAMI_RESOURCE_ID }} + run: | + az extension add --name containerapp --upgrade --only-show-errors + az containerapp update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCEGROUP" \ + --image "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}" - - name: Assign Managed Identity to Container App and Set Secrets and Environment Variables - uses: azure/CLI@v3 - env: - CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} - RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} - CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }} - KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }} - MANAGEDIDENTITYID: ${{ secrets.WEB_UAMI_RESOURCE_ID }} - AZURECLIENTID: ${{ secrets.WEB_UAMI_CLIENT_ID }} - TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }} - with: - inlineScript: | - az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \ - github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \ - msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \ - emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \ - emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \ - captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \ - appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \ - ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID \ - ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \ - ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \ - postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/ConnectionStrings--PostgresVectorStore,identityref:$MANAGEDIDENTITYID - az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP \ - --image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} \ - --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \ - Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \ - AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \ - AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \ - AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ - TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \ - HCaptcha__ExpectedHostname=essentialcsharp.com - name: Logout of Azure CLI if: always() uses: azure/CLI@v3 @@ -193,8 +163,15 @@ jobs: cancel-in-progress: false environment: name: "Production" + permissions: + id-token: write + contents: write # needed for git deploy tag steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Azure Login uses: azure/login@v3 with: @@ -202,71 +179,65 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Download artifact - uses: actions/download-artifact@v8 - with: - name: essentialcsharpwebimage - path: ${{ github.workspace }} - - - name: Load image + # Copy image from dev ACR to prod ACR (server-side, no artifact download needed) + - name: Import image from dev ACR to prod ACR + id: import run: | - docker load --input ${{ github.workspace }}/essentialcsharpwebimage.tar - docker image ls -a - - - name: Log in to container registry + PROD_ACR="${{ vars.PRODCONTAINER_REGISTRY }}" + az acr import \ + --name "${PROD_ACR%.azurecr.io}" \ + --source "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}" \ + --image "essentialcsharpweb:${{ github.sha }}" \ + --image "essentialcsharpweb:latest" \ + --force + DIGEST=$(az acr repository show-manifests \ + --name "${PROD_ACR%.azurecr.io}" \ + --repository essentialcsharpweb \ + --query "[?tags[?@=='${{ github.sha }}']].digest | [0]" -o tsv) + echo "digest=$DIGEST" >> $GITHUB_OUTPUT + + - name: Deploy to Container App + env: + CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} + RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} run: | - REGISTRY="${{ vars.PRODCONTAINER_REGISTRY }}" - az acr login --name "${REGISTRY%.azurecr.io}" + az extension add --name containerapp --upgrade --only-show-errors + az containerapp update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCEGROUP" \ + --image "${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}" - - name: Push Image to Container Registry - run: docker push --all-tags ${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb - - - name: Configure Container App Identity and Registry - uses: azure/CLI@v3 + - name: Verify deployed image env: CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} - CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }} - with: - inlineScript: | - # Container app must already exist; use az containerapp up manually to bootstrap if needed - az extension add --name containerapp --upgrade - az containerapp identity assign --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --user-assigned ${{ secrets.WEB_UAMI_RESOURCE_ID }} - az containerapp registry set --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --server $CONTAINER_REGISTRY --identity ${{ secrets.WEB_UAMI_RESOURCE_ID }} - - - name: Assign Managed Identity to Container App and Set Secrets and Environment Variables - uses: azure/CLI@v3 + run: | + DEPLOYED=$(az containerapp show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCEGROUP" \ + --query "properties.template.containers[0].image" -o tsv) + EXPECTED="${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}" + if [ "$DEPLOYED" != "$EXPECTED" ]; then + echo "::error::Image mismatch! Expected $EXPECTED but found $DEPLOYED" + exit 1 + fi + echo "Deployed image verified: $DEPLOYED" + + - name: Smoke test env: CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }} RESOURCEGROUP: ${{ vars.RESOURCEGROUP }} - CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }} - KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }} - MANAGEDIDENTITYID: ${{ secrets.WEB_UAMI_RESOURCE_ID }} - AZURECLIENTID: ${{ secrets.WEB_UAMI_CLIENT_ID }} - TRYDOTNET_ORIGIN: ${{ vars.PROD_TRYDOTNET_ORIGIN }} - with: - inlineScript: | - az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \ - github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \ - msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \ - emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \ - emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \ - captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \ - appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \ - ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID \ - ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \ - ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \ - postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/ConnectionStrings--PostgresVectorStore,identityref:$MANAGEDIDENTITYID - az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP \ - --image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} \ - --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \ - Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \ - AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \ - AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \ - AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ - TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \ - HCaptcha__ExpectedHostname=essentialcsharp.com + run: | + FQDN=$(az containerapp show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCEGROUP" \ + --query "properties.configuration.ingress.fqdn" -o tsv) + curl --fail --retry 5 --retry-delay 10 "https://$FQDN/health" + + - name: Tag commit as deployed + run: | + git tag "deployed/prod/${{ github.sha }}" + git push origin "deployed/prod/${{ github.sha }}" - name: Logout of Azure CLI if: always() From 8f096bae8241e2e89388ac34e321c99815d8abdd Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 12 May 2026 10:24:05 -0700 Subject: [PATCH 2/2] fix: address review issues in CD workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deprecated az acr repository show-manifests with az acr repository show - Add --registry flag to az acr import for explicit ARM auth on source ACR (prod OIDC identity must have AcrPull on dev ACR — Terraform RBAC required) - Guard against empty digest capture to fail fast with clear error - Add --retry-all-errors to curl so HTTP 5xx triggers retry (not just network errors) - Increase smoke test retry budget to cover cold-start (10x15s = 2.5 min + 30s max-time) - Use git tag -f + push --force so re-runs of same SHA don't fail Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build-Test-And-Deploy.yml | 27 +++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index ff425f8f..dc8a6d84 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -179,21 +179,28 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # Copy image from dev ACR to prod ACR (server-side, no artifact download needed) + # Server-side copy from dev ACR to prod ACR — no artifact download needed. + # PREREQUISITE: prod OIDC identity must have AcrPull on the dev ACR (Terraform RBAC). - name: Import image from dev ACR to prod ACR id: import run: | + DEV_ACR="${{ vars.DEVCONTAINER_REGISTRY }}" PROD_ACR="${{ vars.PRODCONTAINER_REGISTRY }}" az acr import \ --name "${PROD_ACR%.azurecr.io}" \ - --source "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}" \ + --source "${DEV_ACR}/essentialcsharpweb:${{ github.sha }}" \ + --registry "${DEV_ACR%.azurecr.io}" \ --image "essentialcsharpweb:${{ github.sha }}" \ --image "essentialcsharpweb:latest" \ --force - DIGEST=$(az acr repository show-manifests \ + DIGEST=$(az acr repository show \ --name "${PROD_ACR%.azurecr.io}" \ - --repository essentialcsharpweb \ - --query "[?tags[?@=='${{ github.sha }}']].digest | [0]" -o tsv) + --image "essentialcsharpweb:${{ github.sha }}" \ + --query "digest" -o tsv) + if [ -z "$DIGEST" ]; then + echo "::error::Failed to capture image digest from prod ACR after import" + exit 1 + fi echo "digest=$DIGEST" >> $GITHUB_OUTPUT - name: Deploy to Container App @@ -232,12 +239,16 @@ jobs: --name "$CONTAINER_APP_NAME" \ --resource-group "$RESOURCEGROUP" \ --query "properties.configuration.ingress.fqdn" -o tsv) - curl --fail --retry 5 --retry-delay 10 "https://$FQDN/health" + # --retry-all-errors ensures HTTP 5xx (cold-start 503s) also trigger retries + curl --fail --retry 10 --retry-delay 15 --retry-all-errors --max-time 30 "https://$FQDN/health" - name: Tag commit as deployed run: | - git tag "deployed/prod/${{ github.sha }}" - git push origin "deployed/prod/${{ github.sha }}" + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + # -f allows re-tagging the same SHA on workflow re-runs + git tag -f "deployed/prod/${{ github.sha }}" + git push origin "deployed/prod/${{ github.sha }}" --force - name: Logout of Azure CLI if: always()