Step 11: Automate delivery with GitHub Actions
This is the final step of the enterprise web app learning path.
You have deployed Zava Widgets by hand throughout this path. Enterprise teams do not
ship that way - they push code and a pipeline takes it to production the same way every
time. In this step you wire up GitHub Actions to build the app, deploy it to the
staging slot from step 7, and swap it into production on every push to main -
all authenticated with OpenID Connect (OIDC), so there is no publish profile or
password stored in GitHub.
OIDC lets the workflow request a short-lived token from Entra ID at run time instead of holding a secret. You create an Entra app registration, tell it to trust your GitHub repository, and grant it permission to deploy to the web app.
In this step you will:
- Create an Entra app registration and a federated credential that trusts your repo.
- Grant it permission to deploy to the web app.
- Add a GitHub Actions workflow that deploys to staging and swaps to production.
Estimated time: 25 to 35 minutes.
Objectives
By the end of this step you will be able to:
- Explain how GitHub OIDC replaces stored deployment credentials.
- Create a federated credential that trusts a GitHub repository.
- Author a workflow that deploys to a slot and swaps to production.
Before you start
You need the resource group, web app, and the staging slot from
step 7, and your own fork of
the sample repository on GitHub. Reuse your variables:
RESOURCE_GROUP="rg-zava-widgets"
APP_NAME="<your-app-name>"
GITHUB_ORG="<your-github-username>"
GITHUB_REPO="app-service-labs"
How keyless deployment works
The workflow authenticates to Azure with OIDC. When it runs, GitHub issues a signed token that describes the workflow (repo, branch). Entra ID trusts that token because of the federated credential you attach to an app registration, and hands back an Azure access token. The app registration has a role assignment on the web app, so it can deploy. Nothing secret is stored in GitHub.
Create the app registration and federated credential
Create an app registration and a service principal, then add a federated credential
that trusts pushes to main in your fork:
APP_ID=$(az ad app create --display-name "zava-widgets-gh-actions" --query appId -o tsv)
az ad sp create --id "$APP_ID"
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"github-main\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
Grant the app registration permission to deploy to your web app. Scoping the role to the web app (not the whole subscription) keeps it least-privilege:
APP_RESOURCE_ID=$(az webapp show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" --query id -o tsv)
az role assignment create \
--assignee "$APP_ID" \
--role "Contributor" \
--scope "$APP_RESOURCE_ID"
Add the workflow secrets
The workflow needs three non-secret identifiers to sign in with OIDC. Add them as repository secrets in your fork (Settings > Secrets and variables > Actions):
echo "AZURE_CLIENT_ID = $APP_ID"
echo "AZURE_TENANT_ID = $(az account show --query tenantId -o tsv)"
echo "AZURE_SUBSCRIPTION_ID = $(az account show --query id -o tsv)"
Add AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID with those
values. These are identifiers, not passwords - the actual credential is the federated
trust you created above.
Add the workflow
Create .github/workflows/deploy-zava.yml in your fork. It builds the app, deploys to
the staging slot, then swaps staging into production:
name: Deploy Zava Widgets
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
APP_NAME: <your-app-name>
RESOURCE_GROUP: rg-zava-widgets
PACKAGE_PATH: samples/zava-widgets
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: ${{ env.PACKAGE_PATH }}
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.APP_NAME }}
slot-name: staging
package: ${{ env.PACKAGE_PATH }}
- name: Swap staging into production
run: |
az webapp deployment slot swap \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--slot staging --target-slot production
Replace <your-app-name> with your web app's name. Commit and push the workflow to
main.
Verify
Push a change to main (or run the workflow manually from the Actions tab with
Run workflow). Watch the run in the Actions tab: the job signs in with OIDC,
deploys to staging, and swaps to production. When it finishes, confirm production is
serving the deployed build:
APP_URL="https://$(az webapp show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" --query defaultHostName -o tsv)"
curl -s "$APP_URL/api/info"
You can confirm the Azure-side setup independently of a workflow run. The federated credential and role assignment both exist:
az ad app federated-credential list --id "$APP_ID" \
--query "[].{name:name, subject:subject}" -o table
az role assignment list --assignee "$APP_ID" --scope "$APP_RESOURCE_ID" \
--query "[].roleDefinitionName" -o tsv
The first lists your github-main credential and its repo:.../main subject; the
second returns Contributor. With those in place, the workflow can deploy without any
stored password.
The workflow deploys to the staging slot and swaps, so production is only ever updated
by a warm, already-running slot. If a deployment is bad, you swap back - exactly the
safe-release pattern from step 7, now automated.
Troubleshooting
azure/loginfails with AADSTS70021 (no matching federated identity record). The token's subject must match the federated credential exactly. A push tomainproducesrepo:<org>/<repo>:ref:refs/heads/main; a pull request or tag produces a different subject and needs its own credential.- Deploy step returns 403. The app registration needs a role assignment on the web
app. Confirm the
Contributor(or Website Contributor) assignment on the app's resource ID. - Swap fails because the slot is not ready. The deploy step must finish before the swap. Keep them as ordered steps in the same job, as shown.
Summary
Zava Widgets now ships itself. A push to main builds the app, deploys it to a staging
slot, and swaps it into production - authenticated with OIDC, with no deployment
secrets in GitHub. That completes the path: you took one app from a basic deployment
all the way to an enterprise-grade web app - externalized configuration, a managed-identity
database, Key Vault secrets, health checks, autoscale, safe slot releases, monitoring
and alerts, Entra sign-in, private networking, and automated delivery. Congratulations.
When you are done exploring, delete the resource group to stop billing:
az group delete --name "$RESOURCE_GROUP" --yes --no-wait