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