Overview
This lab closes the gap between a merged change and a running product. You will extend the pipeline so that every merge to main publishes the container image to a registry, deploys it to Azure Container Apps as a new revision, and then checks that the live site is really serving the new version. No password for Azure is stored anywhere. You will then ship a visible change, roll it back, and roll forward again, all through the pipeline.
Learning Objectives
By the end of this lab you will be able to:
- Publish a versioned container image to a registry from a pipeline
- Explain how OpenID Connect lets a pipeline authenticate to a cloud provider without a stored secret
- Deploy a new revision to Azure Container Apps automatically on merge
- Verify a deployment with a post-deployment smoke test
- Roll back to an earlier revision and explain why that is fast
Concept
A pipeline that stops at "the tests passed" leaves the riskiest step, putting the software in front of users, to a person following a list. Manual deployments are slow, so they are done rarely. Rare deployments are large, so they are frightening, and every one is slightly different from the last.
Continuous deployment makes release an automatic consequence of merging. The same image that was tested is the one that ships, identified by the commit it was built from. A container registry stores images so that a hosting platform can pull them. Here it is GitHub Container Registry. Azure Container Apps runs containers without you managing servers. Each deployment creates a revision, an unchangeable snapshot of the app with one specific image. Ingress traffic is directed at a revision, so rolling back is redirecting traffic to an older revision that still exists. Nothing is rebuilt.
The pipeline has to prove to Azure who it is. Storing a password as a secret works until it leaks or expires. OpenID Connect avoids storing anything. GitHub issues the running job a short-lived signed token stating which repository and branch it belongs to. Azure has been told in advance to trust tokens with exactly that description, through a federated credential on a managed identity, and exchanges it for temporary access. A smoke test then asks the live site a simple question to confirm the deployment worked.
Tasks
Task 1 - Find out what you have been given
Your lecturer has created one Container App for you and will give you six values: a client ID, a tenant ID, a subscription ID, a resource group name, a Container App name, and the public address of your app. None of them is a password. They identify things, and knowing them grants nothing.
The identity those values refer to has been given permission to update your Container App and nothing else. It will only accept requests from workflows running on the main branch of your repository. You cannot create other resources in Azure, and you cannot affect another student's app.
Open the public address in a browser.
Success condition: a Notes page loads. It is a placeholder build put there when the app was created, not yours. Add /api/health to the address and note the version it reports. The first request may take several seconds, because the app scales down to zero containers when nobody is using it and has to start one.
Task 2 - Store the settings as repository variables
- In your repository open Settings, Secrets and variables, Actions, and choose the Variables tab. Not the Secrets tab.
- Create five repository variables with exactly these names, using the values you were given.
| Name | Value |
|---|---|
AZURE_CLIENT_ID | the client ID |
AZURE_TENANT_ID | the tenant ID |
AZURE_SUBSCRIPTION_ID | the subscription ID |
AZURE_RESOURCE_GROUP | the resource group name |
AZURE_CONTAINER_APP | your Container App name |
Variables are readable by anybody who can see the workflow. That is acceptable here, because there is no secret among them. The Secrets tab stays empty for the whole of this lab.
Task 3 - Make the CI workflow reusable
Create an issue named Deploy automatically on merge, move it to In progress, and start a branch.
git switch main
git pull
git switch -c cd/publish-image
The deployment must not skip the checks. Rather than copy them, let another workflow call the existing one. Open .github/workflows/ci.yml and add workflow_call to its on section, so that the section reads:
on:
pull_request:
branches: [main]
# Lets the deploy workflow run these same checks before it publishes.
workflow_call:
Task 4 - Publish the image on every merge
Create .github/workflows/deploy.yml with the content below. It is the first half of the deployment workflow.
on: pushtomainis what a merged pull request produces.concurrencyqueues a second run behind the first, so two deployments never overlap.- The
cijob runs the whole CI workflow again, this time against the merged result. - The
publishjob is the only one grantedpackages: write. It logs in to the registry with the workflow's own temporary token, builds the image with the commit identifier as its version, tags it with that same identifier, and pushes it. outputshands the full image name to whichever job comes next.
name: Deploy
on:
push:
branches: [main]
# One deployment at a time. A second merge waits for the first to finish.
concurrency: deploy-main
permissions:
contents: read
jobs:
ci:
name: CI
uses: ./.github/workflows/ci.yml
publish:
name: Publish image
runs-on: ubuntu-latest
needs: ci
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.name.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Work out the image name
id: name
# Registry names must be lower case, and GitHub user names may not be.
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build the image
run: docker build --build-arg APP_VERSION=${{ github.sha }} -t "${{ steps.name.outputs.image }}" .
- name: Push the image
run: docker push "${{ steps.name.outputs.image }}"
Commit, push, open a pull request, wait for the four checks, and merge it. Do not close the issue yet.
git add .github
git commit -m "Publish image to GHCR on merge"
git push -u origin cd/publish-image
Success condition: after the merge, the Actions tab shows a new Deploy run on main. The four CI jobs run, then Publish image. When it finishes, the front page of your repository lists a package named notes-devops under Packages.
Task 5 - Make the package public
A new package is private. Azure would need a registry password to pull it, which is exactly the kind of stored secret this design avoids. The source is already public, so the image can be too.
- Click the
notes-devopspackage on your repository's front page. - Open Package settings.
- Under Danger Zone choose Change visibility, select Public, and confirm.
Success condition: the package page shows a Public label, and lists one version tagged with a 40-character commit identifier.
Task 6 - Deploy and verify
git switch main
git pull
git switch -c cd/deploy-to-azure
Open .github/workflows/deploy.yml and add the deploy job to the end. deploy: lines up with publish:.
id-token: writeallows this one job to ask GitHub for an identity token. Without it the login step has nothing to present.azure/loginis given three identifiers and no secret. It performs the token exchange described in the Concept section.az containerapp update --imageis the deployment. It creates a new revision running the image thatpublishjust pushed. The revision's name includes the short commit identifier, so you can tell revisions apart.- The last step writes a table of revisions to the run's summary page.
deploy:
name: Deploy to Azure Container Apps
runs-on: ubuntu-latest
needs: publish
permissions:
contents: read
# Allows this job to request the short-lived token Azure exchanges.
id-token: write
outputs:
url: ${{ steps.url.outputs.url }}
steps:
- name: Log in to Azure without a stored secret
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Create a new revision from the new image
run: |
az containerapp update \
--name "${{ vars.AZURE_CONTAINER_APP }}" \
--resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
--image "${{ needs.publish.outputs.image }}" \
--revision-suffix "sha-${GITHUB_SHA::7}-${GITHUB_RUN_ATTEMPT}" \
--output none
- name: Look up the public address
id: url
run: |
fqdn=$(az containerapp show \
--name "${{ vars.AZURE_CONTAINER_APP }}" \
--resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
--query properties.configuration.ingress.fqdn --output tsv)
echo "url=https://${fqdn}" >> "$GITHUB_OUTPUT"
- name: List revisions in the run summary
run: |
{
echo "### Revisions"
echo '```'
az containerapp revision list \
--name "${{ vars.AZURE_CONTAINER_APP }}" \
--resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
--query "[].{name:name, active:properties.active, traffic:properties.trafficWeight, created:properties.createdTime}" \
--output table
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
Then add the smoke job. "The deployment command succeeded" and "users are getting the new version" are different claims. This job checks the second one. It asks the live health endpoint which version is running, and passes only when the answer is the commit that was just deployed. It retries for up to five minutes, because a new revision takes time to start.
smoke:
name: Smoke test
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Wait for the new version to answer
run: |
url="${{ needs.deploy.outputs.url }}/api/health"
for attempt in $(seq 1 20); do
version=$(curl --silent --fail --max-time 10 "$url" | jq -r .version || true)
echo "Attempt ${attempt}: version=${version}"
if [ "$version" = "$GITHUB_SHA" ]; then
echo "The live site is serving ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
sleep 15
done
echo "The live site never reported version ${GITHUB_SHA}"
exit 1
Create one more workflow, .github/workflows/rollback.yml. It is started by hand, not by a push. You will use it shortly.
name: Rollback
on:
workflow_dispatch:
inputs:
revision:
description: Revision to send all traffic to. Type latest to return to normal. Leave empty to only list revisions.
required: false
default: ''
concurrency: deploy-main
permissions:
contents: read
id-token: write
jobs:
rollback:
name: Move traffic
runs-on: ubuntu-latest
steps:
- name: Log in to Azure without a stored secret
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Move all traffic to the chosen revision
if: ${{ inputs.revision != '' }}
env:
REVISION: ${{ inputs.revision }}
run: |
az containerapp ingress traffic set \
--name "${{ vars.AZURE_CONTAINER_APP }}" \
--resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
--revision-weight "${REVISION}=100" \
--output none
- name: List revisions in the run summary
run: |
{
echo "### Revisions"
echo '```'
az containerapp revision list \
--name "${{ vars.AZURE_CONTAINER_APP }}" \
--resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
--query "[].{name:name, active:properties.active, traffic:properties.trafficWeight, created:properties.createdTime}" \
--output table
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
Commit, push, open a pull request, wait for green, and merge.
git add .github
git commit -m "Deploy to Azure Container Apps with smoke test"
git push -u origin cd/deploy-to-azure
Success condition: the Deploy run on main shows seven jobs in a chain ending with a green Smoke test. Its log shows the version changing from the old value to your commit identifier. Open your app's address. It is now your build. /api/health reports your commit identifier as the version.
If Log in to Azure fails, check the five variable names and values for typing mistakes, and check that the run is on main. If it still fails, give your lecturer the exact error message. It usually means the repository name they registered does not match yours exactly.
Task 7 - Ship a visible change
Close the deployment issue. Then deliver a small change the way every change will now travel. Create an issue named Personalise the page heading and a branch for it.
git switch main
git pull
git switch -c feature/page-heading
Open client/src/App.tsx and change the main heading from Notes to My Notes. Commit, push, open a pull request that closes the issue, wait for green, and merge. Then do nothing.
git commit -am "Personalise the page heading"
git push -u origin feature/page-heading
Success condition: without any further action from you, the live site shows My Notes a few minutes after the merge. Time it, from pressing merge to seeing the change. That number is your lead time for a change that is already written.
Task 8 - Roll back
Suppose the new heading turned out to be a mistake and has to go, now.
- Open the Actions tab, choose the Rollback workflow, and choose Run workflow. Leave the revision box empty and run it.
- Open the finished run. Its summary lists your revisions with their creation times and how much traffic each receives. The newest has 100.
- Copy the name of the revision created just before the newest one.
- Run the Rollback workflow again, this time pasting that name into the revision box.
Success condition: within a minute the live site says Notes again, and /api/health reports the earlier commit. The summary shows the older revision with 100 and the newest with 0. Nothing was rebuilt, retested or redeployed. Traffic was pointed at something that already existed. Compare the time this took with your lead time from the previous task.
The repository still contains the "bad" change, and the site is now pinned to an old revision. A new deployment would create a revision that receives no traffic. Rollback buys time. It does not fix anything. Return to normal by running Rollback once more with latest in the revision box, and confirm the site says My Notes.
Task 9 - Look at what the running app logs
This task needs the Azure portal. If your lecturer has not given your account access to the resource group, read it and move on.
- Sign in to the Azure portal, open the resource group, and open your Container App.
- Under Monitoring open Log stream. Reload your live site a few times and watch for output. You should see the start-up line
Notes API listening on port 3000whenever a new container starts. - Under Application open Revisions and replicas and find the same revisions the pipeline listed.
You have read access only. Notice that everything you changed in Azure during this lab was changed by the pipeline, and is recorded in the history of the Actions tab with the commit that caused it.
Task 10 - Evaluate
Explain why nothing in this pipeline would need to change, and nothing would need to be revoked, if the entire contents of the repository settings page were leaked.
The smoke test checks that the health endpoint reports the new version. Name one failure it would catch that the deploy step would not, and one failure it would still miss.
Every merge to main now goes live without a person approving the release. Argue for or against that in the context of this product, then say what would change your answer.
Solution
Try the tasks above before revealing the solution.