Skip to content

Commit 399f175

Browse files
ci: simplify deploy jobs now that Terraform manages Container App config
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/<sha>) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf6fab5 commit 399f175

1 file changed

Lines changed: 72 additions & 101 deletions

File tree

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

Lines changed: 72 additions & 101 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,81 @@ 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+
# Copy image from dev ACR to prod ACR (server-side, no artifact download needed)
183+
- name: Import image from dev ACR to prod ACR
184+
id: import
212185
run: |
213-
docker load --input ${{ github.workspace }}/essentialcsharpwebimage.tar
214-
docker image ls -a
215-
216-
- name: Log in to container registry
186+
PROD_ACR="${{ vars.PRODCONTAINER_REGISTRY }}"
187+
az acr import \
188+
--name "${PROD_ACR%.azurecr.io}" \
189+
--source "${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }}" \
190+
--image "essentialcsharpweb:${{ github.sha }}" \
191+
--image "essentialcsharpweb:latest" \
192+
--force
193+
DIGEST=$(az acr repository show-manifests \
194+
--name "${PROD_ACR%.azurecr.io}" \
195+
--repository essentialcsharpweb \
196+
--query "[?tags[?@=='${{ github.sha }}']].digest | [0]" -o tsv)
197+
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
198+
199+
- name: Deploy to Container App
200+
env:
201+
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
202+
RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
217203
run: |
218-
REGISTRY="${{ vars.PRODCONTAINER_REGISTRY }}"
219-
az acr login --name "${REGISTRY%.azurecr.io}"
204+
az extension add --name containerapp --upgrade --only-show-errors
205+
az containerapp update \
206+
--name "$CONTAINER_APP_NAME" \
207+
--resource-group "$RESOURCEGROUP" \
208+
--image "${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}"
220209
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
210+
- name: Verify deployed image
226211
env:
227212
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
228213
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 }}
236-
237-
- name: Assign Managed Identity to Container App and Set Secrets and Environment Variables
238-
uses: azure/CLI@v3
214+
run: |
215+
DEPLOYED=$(az containerapp show \
216+
--name "$CONTAINER_APP_NAME" \
217+
--resource-group "$RESOURCEGROUP" \
218+
--query "properties.template.containers[0].image" -o tsv)
219+
EXPECTED="${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb@${{ steps.import.outputs.digest }}"
220+
if [ "$DEPLOYED" != "$EXPECTED" ]; then
221+
echo "::error::Image mismatch! Expected $EXPECTED but found $DEPLOYED"
222+
exit 1
223+
fi
224+
echo "Deployed image verified: $DEPLOYED"
225+
226+
- name: Smoke test
239227
env:
240228
CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
241229
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
230+
run: |
231+
FQDN=$(az containerapp show \
232+
--name "$CONTAINER_APP_NAME" \
233+
--resource-group "$RESOURCEGROUP" \
234+
--query "properties.configuration.ingress.fqdn" -o tsv)
235+
curl --fail --retry 5 --retry-delay 10 "https://$FQDN/health"
236+
237+
- name: Tag commit as deployed
238+
run: |
239+
git tag "deployed/prod/${{ github.sha }}"
240+
git push origin "deployed/prod/${{ github.sha }}"
270241
271242
- name: Logout of Azure CLI
272243
if: always()

0 commit comments

Comments
 (0)