Skip to content

Commit ffaa95d

Browse files
ci: simplify deploy workflows — Terraform owns Container App config, CI owns image version (#1081)
## Summary Now that Terraform manages the Container App configuration, the deploy jobs no longer need to re-configure identity, registry, secrets, or env vars on every run. This PR strips those steps and adds production hardening. ## What changed ### Removed (Terraform owns these now) - `az containerapp identity assign` — UAMI is set in Terraform HCL - `az containerapp registry set` — ACR pull via managed identity is in Terraform HCL - `az containerapp secret set` — all Key Vault secret refs are in Terraform HCL - `--replace-env-vars` on `az containerapp update` — env vars are in Terraform HCL ### Changed - `deploy-development`: now just loads artifact → pushes to dev ACR → `az containerapp update --image :sha` - `deploy-production`: replaced artifact download + push with **`az acr import`** (server-side copy from dev ACR to prod ACR — faster, no large artifact download) - Build job: removed prod registry tags (image only goes to dev ACR at build time; prod gets it via import) ### Added (production hardening) - Deploy by **image digest** (`@sha256:...`) instead of mutable tag — immutable reference - **Post-deploy image verification** — reads deployed image from Container App, asserts it matches expected digest; catches silent rollback if Terraform recreates the resource - **Smoke test** — `curl --fail /health` on the Container App FQDN - **Git deploy tag** — `deployed/prod/<sha>` pushed to repo as a durable audit record ## Prerequisites (confirm before merging) Verify Terraform HCL for the web Container App has all of: - [ ] `identity { user_assigned_identity_ids = [...] }` — UAMI attached - [ ] `registry { ... identity = uami_id }` — ACR pull via managed identity - [ ] All 14 secrets as Key Vault refs under `secret { key_vault_secret_uri }` - [ ] All env vars under `template.container.env` - [ ] OIDC identity has `AcrPush` (or `AcrImporter`) on **prod ACR** for `az acr import` ## RBAC note The OIDC identity for this repo needs `AcrPush` on the prod ACR in addition to the dev ACR — `az acr import` writes to prod. Terraform should own this role assignment. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d59e80c commit ffaa95d

1 file changed

Lines changed: 81 additions & 99 deletions

File tree

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 81 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
- name: Set up Docker Buildx
6464
uses: docker/setup-buildx-action@v4
6565

66-
# Build but no push with a PR
66+
# Build but no push with a PR
6767
- name: Docker build (no push)
6868
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
6969
uses: docker/build-push-action@v7
@@ -74,18 +74,20 @@ jobs:
7474
context: .
7575
build-args: ACCESS_TO_NUGET_FEED=false
7676

77+
# Only build for dev registry — prod gets the image via az acr import in deploy-production
7778
- name: Build Container Image
7879
if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
7980
uses: docker/build-push-action@v7
8081
with:
81-
tags: ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:latest,${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:latest
82+
tags: ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:latest
8283
file: ./EssentialCSharp.Web/Dockerfile
8384
context: .
8485
secrets: |
8586
"nuget_pat=${{ secrets.AZURE_DEVOPS_PAT }}"
8687
outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
8788
cache-from: type=gha
8889
cache-to: type=gha,mode=max
90+
8991
- name: Upload artifact
9092
uses: actions/upload-artifact@v7
9193
with:
@@ -101,6 +103,9 @@ jobs:
101103
cancel-in-progress: false
102104
environment:
103105
name: "Development"
106+
permissions:
107+
id-token: write
108+
contents: read
104109

105110
steps:
106111
- name: Azure Login
@@ -126,55 +131,20 @@ jobs:
126131
REGISTRY="${{ vars.DEVCONTAINER_REGISTRY }}"
127132
az acr login --name "${REGISTRY%.azurecr.io}"
128133
129-
- name: Push Image to Container Registry
134+
- name: Push Image to Dev Container Registry
130135
run: docker push --all-tags ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb
131136

132-
- name: Configure Container App Identity and Registry
133-
uses: azure/CLI@v3
137+
- name: Deploy to Container App
134138
env:
135139
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
136140
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
137-
CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }}
138-
with:
139-
inlineScript: |
140-
# Container app must already exist; use az containerapp up manually to bootstrap if needed
141-
az extension add --name containerapp --upgrade
142-
az containerapp identity assign --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --user-assigned ${{ secrets.WEB_UAMI_RESOURCE_ID }}
143-
az containerapp registry set --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --server $CONTAINER_REGISTRY --identity ${{ secrets.WEB_UAMI_RESOURCE_ID }}
141+
run: |
142+
az extension add --name containerapp --upgrade --only-show-errors
143+
az containerapp update \
144+
--name "$CONTAINER_APP_NAME" \
145+
--resource-group "$RESOURCEGROUP" \
146+
--image "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}"
144147
145-
- name: Assign Managed Identity to Container App and Set Secrets and Environment Variables
146-
uses: azure/CLI@v3
147-
env:
148-
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
149-
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
150-
CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }}
151-
KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }}
152-
MANAGEDIDENTITYID: ${{ secrets.WEB_UAMI_RESOURCE_ID }}
153-
AZURECLIENTID: ${{ secrets.WEB_UAMI_CLIENT_ID }}
154-
TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }}
155-
with:
156-
inlineScript: |
157-
az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \
158-
github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \
159-
msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \
160-
emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \
161-
emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \
162-
captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \
163-
appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \
164-
ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID \
165-
ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \
166-
ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \
167-
postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/ConnectionStrings--PostgresVectorStore,identityref:$MANAGEDIDENTITYID
168-
az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP \
169-
--image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} \
170-
--replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \
171-
Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \
172-
AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \
173-
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \
174-
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
175-
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
176-
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \
177-
HCaptcha__ExpectedHostname=essentialcsharp.com
178148
- name: Logout of Azure CLI
179149
if: always()
180150
uses: azure/CLI@v3
@@ -193,80 +163,92 @@ jobs:
193163
cancel-in-progress: false
194164
environment:
195165
name: "Production"
166+
permissions:
167+
id-token: write
168+
contents: write # needed for git deploy tag
196169

197170
steps:
171+
- uses: actions/checkout@v6
172+
with:
173+
fetch-depth: 0
174+
198175
- name: Azure Login
199176
uses: azure/login@v3
200177
with:
201178
client-id: ${{ secrets.AZURE_CLIENT_ID }}
202179
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
203180
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
204181

205-
- name: Download artifact
206-
uses: actions/download-artifact@v8
207-
with:
208-
name: essentialcsharpwebimage
209-
path: ${{ github.workspace }}
210-
211-
- name: Load image
182+
# Server-side copy from dev ACR to prod ACR — no artifact download needed.
183+
# PREREQUISITE: prod OIDC identity must have AcrPull on the dev ACR (Terraform RBAC).
184+
- name: Import image from dev ACR to prod ACR
185+
id: import
212186
run: |
213-
docker load --input ${{ github.workspace }}/essentialcsharpwebimage.tar
214-
docker image ls -a
187+
DEV_ACR="${{ vars.DEVCONTAINER_REGISTRY }}"
188+
PROD_ACR="${{ vars.PRODCONTAINER_REGISTRY }}"
189+
az acr import \
190+
--name "${PROD_ACR%.azurecr.io}" \
191+
--source "${DEV_ACR}/essentialcsharpweb:${{ github.sha }}" \
192+
--registry "${DEV_ACR%.azurecr.io}" \
193+
--image "essentialcsharpweb:${{ github.sha }}" \
194+
--image "essentialcsharpweb:latest" \
195+
--force
196+
DIGEST=$(az acr repository show \
197+
--name "${PROD_ACR%.azurecr.io}" \
198+
--image "essentialcsharpweb:${{ github.sha }}" \
199+
--query "digest" -o tsv)
200+
if [ -z "$DIGEST" ]; then
201+
echo "::error::Failed to capture image digest from prod ACR after import"
202+
exit 1
203+
fi
204+
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
215205
216-
- name: Log in to container registry
206+
- name: Deploy to Container App
207+
env:
208+
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
209+
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
217210
run: |
218-
REGISTRY="${{ vars.PRODCONTAINER_REGISTRY }}"
219-
az acr login --name "${REGISTRY%.azurecr.io}"
211+
az extension add --name containerapp --upgrade --only-show-errors
212+
az containerapp update \
213+
--name "$CONTAINER_APP_NAME" \
214+
--resource-group "$RESOURCEGROUP" \
215+
--image "${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}"
220216
221-
- name: Push Image to Container Registry
222-
run: docker push --all-tags ${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb
223-
224-
- name: Configure Container App Identity and Registry
225-
uses: azure/CLI@v3
217+
- name: Verify deployed image
226218
env:
227219
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
228220
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
229-
CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }}
230-
with:
231-
inlineScript: |
232-
# Container app must already exist; use az containerapp up manually to bootstrap if needed
233-
az extension add --name containerapp --upgrade
234-
az containerapp identity assign --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --user-assigned ${{ secrets.WEB_UAMI_RESOURCE_ID }}
235-
az containerapp registry set --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --server $CONTAINER_REGISTRY --identity ${{ secrets.WEB_UAMI_RESOURCE_ID }}
221+
run: |
222+
DEPLOYED=$(az containerapp show \
223+
--name "$CONTAINER_APP_NAME" \
224+
--resource-group "$RESOURCEGROUP" \
225+
--query "properties.template.containers[0].image" -o tsv)
226+
EXPECTED="${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}"
227+
if [ "$DEPLOYED" != "$EXPECTED" ]; then
228+
echo "::error::Image mismatch! Expected $EXPECTED but found $DEPLOYED"
229+
exit 1
230+
fi
231+
echo "Deployed image verified: $DEPLOYED"
236232
237-
- name: Assign Managed Identity to Container App and Set Secrets and Environment Variables
238-
uses: azure/CLI@v3
233+
- name: Smoke test
239234
env:
240235
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
241236
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
242-
CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }}
243-
KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }}
244-
MANAGEDIDENTITYID: ${{ secrets.WEB_UAMI_RESOURCE_ID }}
245-
AZURECLIENTID: ${{ secrets.WEB_UAMI_CLIENT_ID }}
246-
TRYDOTNET_ORIGIN: ${{ vars.PROD_TRYDOTNET_ORIGIN }}
247-
with:
248-
inlineScript: |
249-
az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \
250-
github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \
251-
msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \
252-
emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \
253-
emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \
254-
captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \
255-
appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \
256-
ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID \
257-
ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \
258-
ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \
259-
postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/ConnectionStrings--PostgresVectorStore,identityref:$MANAGEDIDENTITYID
260-
az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP \
261-
--image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} \
262-
--replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \
263-
Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \
264-
AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \
265-
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \
266-
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
267-
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
268-
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \
269-
HCaptcha__ExpectedHostname=essentialcsharp.com
237+
run: |
238+
FQDN=$(az containerapp show \
239+
--name "$CONTAINER_APP_NAME" \
240+
--resource-group "$RESOURCEGROUP" \
241+
--query "properties.configuration.ingress.fqdn" -o tsv)
242+
# --retry-all-errors ensures HTTP 5xx (cold-start 503s) also trigger retries
243+
curl --fail --retry 10 --retry-delay 15 --retry-all-errors --max-time 30 "https://$FQDN/health"
244+
245+
- name: Tag commit as deployed
246+
run: |
247+
git config user.email "github-actions[bot]@users.noreply.github.com"
248+
git config user.name "github-actions[bot]"
249+
# -f allows re-tagging the same SHA on workflow re-runs
250+
git tag -f "deployed/prod/${{ github.sha }}"
251+
git push origin "deployed/prod/${{ github.sha }}" --force
270252
271253
- name: Logout of Azure CLI
272254
if: always()

0 commit comments

Comments
 (0)