GitHub Actions
The deploy workflow the kit generates for GitHub Actions, job by job for each hosting model, and how to set up the secrets, variables and the keyless sign-in to Azure. For developers.
On this page
npx mdlake hosting init --ci github writes .github/workflows/deploy.yml. One workflow, called Deploy, checks the site and deploys it to the Azure resources from Azure and Bicep. This page shows what is in the file for each hosting model, what you must set in GitHub, and how to give the workflow access to Azure without a stored password.
Generate the workflow
npx mdlake hosting init --ci github --name acme-siteAdd azure-devops after a comma (--ci github,azure-devops) to write the Azure DevOps pipeline as well. The first line of the file is a marker that names the model:
# mdlake-hosting: tier=split runtime=dotnet (generated by `npx mdlake hosting init`; keep your edits with `npx mdlake adopt <file>`)The file is in the generated zone. Edit it only after npx mdlake adopt .github/workflows/deploy.yml, or the next hosting init refuses to overwrite it and exits with code 3. See Azure and Bicep.
What every workflow has
| Part | Setting |
|---|---|
| Name | Deploy |
| Triggers | A push to main, and workflow_dispatch (the Run workflow button) |
| Concurrency | Group deploy, cancel-in-progress: false. A second run waits for the first. |
| Permissions | contents: read. The server and split models add id-token: write for the keyless sign-in. |
| Runner | ubuntu-latest |
Pull requests do not trigger a deployment. The workflow has no pull request job.
The check steps are the same on every model:
actions/checkout@v4.actions/setup-node@v4with Node 22 and the npm cache. On a split .NET site,actions/setup-dotnet@v4with10.0.xas well.- A step that runs
git --version, so the log shows that git is there. The lake is fetched with git. npm ci.actions/cache@v4for.mdlake/lake,node_modules/.astro/assetsandnode_modules/.cache/mdlake-assets, keyed onlake.lock. See The lake in CI, and caches.npx playwright install --with-deps chromium, for the accessibility check.npm run check, withMDLAKE_LAKE_TOKEN,SITE_URLandPUBLIC_TURNSTILE_SITE_KEYin its environment.
npm run check runs the ruleset, ownership and hosting checks (and, on a split site, the API contract check), type checking, the build, the binding check and the accessibility check. See Scaffold and build.
The static model
One job, deploy, does the check steps and then the deployment:
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: upload
app_location: dist
skip_app_build: trueThe workflow uploads the dist folder that npm run check built. Static Web Apps does not build anything itself (skip_app_build: true). No Azure sign-in step exists, because the deployment token is the credential.
The server model
One job, deploy, with these steps after the check:
-
Assemble the package. The server needs its production dependencies next to the build:
mkdir deploy cp -r dist package.json package-lock.json deploy/ if [ -d vendor ]; then cp -r vendor deploy/; fi cd deploy && npm ci --omit=dev --ignore-scripts -
Sign in with
azure/login@v2usingAZURE_CLIENT_ID,AZURE_TENANT_IDandAZURE_SUBSCRIPTION_ID. -
Deploy with
azure/webapps-deploy@v3:app-namefrom the variableAZURE_WEBAPP_NAME,package: deploy, andstartup-command: node dist/server/entry.mjs.
The split model
Three jobs run in sequence: check, then api, then site.
| Job | Needs | What it does |
|---|---|---|
check | none | The check steps. It then uploads dist as the workflow artifact site (actions/upload-artifact@v4). |
api | check | Builds the backend, signs in to Azure and deploys the API to its Web App (vars.AZURE_API_APP_NAME). |
site | api | Downloads the artifact site into dist and deploys it to Static Web Apps. |
The API goes first so that the pages never reach an API that is older than they expect.
In the api job the build step depends on the runtime.
| Runtime | Build and package |
|---|---|
| .NET | actions/setup-dotnet@v4 (10.0.x), then dotnet publish api/Api.csproj -c Release -o api-publish. The package is api-publish. |
| Node | actions/setup-node@v4, npm ci, then npm run build --workspace api. The package is api/dist, with startup-command: node server.mjs. |
The site job does not check out the repository, so it spends little time.
Set up GitHub
Do these once, in this order. Reading the output of hosting init helps, since it lists the same steps with your names filled in.
Variables and secrets
Go to the repository, Settings, Secrets and variables, Actions.
| Name | Kind | Static | Server | Split | Meaning |
|---|---|---|---|---|---|
MDLAKE_LAKE_TOKEN | secret | yes | yes | yes | Read-only token for the lake repository. See The lake token. |
SITE_URL | variable | yes | yes | yes | The public address, for example https://www.acme.com. |
PUBLIC_TURNSTILE_SITE_KEY | variable | when used | when used | when used | The public Turnstile site key. It ends up in the page, so it is not secret. See Turnstile. |
AZURE_STATIC_WEB_APPS_API_TOKEN | secret | yes | yes | The Static Web App deployment token. See Get the deployment token. | |
AZURE_CLIENT_ID | secret | yes | yes | The app registration’s client id. | |
AZURE_TENANT_ID | secret | yes | yes | Your Microsoft Entra tenant id. | |
AZURE_SUBSCRIPTION_ID | secret | yes | yes | The subscription id. | |
AZURE_WEBAPP_NAME | variable | yes | The Web App name (the name output). | ||
AZURE_API_APP_NAME | variable | yes | The API Web App, <name>-api (the apiName output). |
With the GitHub CLI you can set them from a terminal:
gh secret set MDLAKE_LAKE_TOKEN
gh variable set SITE_URL --body "https://www.acme.com"
gh secret set AZURE_STATIC_WEB_APPS_API_TOKEN --body "$(az staticwebapp secrets list --name acme-site-prod --query properties.apiKey -o tsv)"In PowerShell:
gh secret set MDLAKE_LAKE_TOKEN
gh variable set SITE_URL --body "https://www.acme.com"
gh secret set AZURE_STATIC_WEB_APPS_API_TOKEN --body (az staticwebapp secrets list --name acme-site-prod --query properties.apiKey -o tsv)gh secret set with no --body asks for the value, which keeps it out of your shell history.
Sign in to Azure
The server and split models use OpenID Connect. GitHub proves to Azure who the workflow is, and Azure hands it a short-lived token. There is no password or client secret to store or rotate.
-
Create an app registration and its service principal.
az ad app create --display-name acme-site-deploy --query appId -o tsv az ad sp create --id <appId>The first command prints the client id. That is
AZURE_CLIENT_ID. -
Add a federated credential for the repository and the
mainbranch. The subject starts with the prefix GitHub presents for your repository, and that prefix can hold numeric IDs, so read it first:gh api repos/<owner>/<repository>/actions/oidc/customization/sub --jq .sub_claim_prefixIt prints something like
repo:acme@1234567/acme-site@987654321. Save this ascredential.json, with what it printed in place of<prefix>:{ "name": "github-main", "issuer": "https://token.actions.githubusercontent.com", "subject": "<prefix>:ref:refs/heads/main", "audiences": ["api://AzureADTokenExchange"] }az ad app federated-credential create --id <appId> --parameters credential.jsonThe generated workflow does not use a GitHub environment, so the subject names the branch. If you add an
environment:to the job, the subject becomes<prefix>:environment:<name>.If the login fails with
AADSTS700213, the subject does not match. The error message names the subject GitHub presented, so copy that one into the credential. -
Give it the role on the Web App it deploys to: Website Contributor.
az role assignment create --assignee <appId> --role "Website Contributor" --scope $(az webapp show --resource-group rg-acme-site --name acme-site-prod-api --query id -o tsv)In PowerShell, store the id first:
$scope = az webapp show --resource-group rg-acme-site --name acme-site-prod-api --query id -o tsv, then use--scope $scope. -
Set the three secrets
AZURE_CLIENT_ID,AZURE_TENANT_ID(az account show --query tenantId -o tsv) andAZURE_SUBSCRIPTION_ID(az account show --query id -o tsv).
The static model needs none of this.
The first run
- Push to
main, or open Actions, choose Deploy and select Run workflow. - Watch the
checkjob. On the first run it fetches the lake and builds every image, so it takes the longest. - When the last job is green, open the site.
Later runs are faster because the caches are restored as long as lake.lock has not changed.
Change the workflow
Typical changes, and how to make them without losing them:
| Change | How |
|---|---|
| Deploy from another branch or on a tag | Edit on: in the file, then npx mdlake adopt .github/workflows/deploy.yml. |
| Add a staging deployment | Copy the file to deploy-staging.yml, change the trigger, the variable names and the app names. A copy that has no marker line is yours. |
| Deploy only after approval | Add environment: production to the deploying job and require reviewers on that environment. Change the federated credential subject to match. |
| Pull request previews | Not generated. Static Web Apps supports them, but the split model’s linked backend does not work in pull request environments. |
Problems
| Symptom | Fix |
|---|---|
npm run check fails with a lake error | The token is missing, expired or too narrow. See The lake in CI, and caches. |
Login failed ... AADSTS70025 or No matching federated identity record found | The credential’s subject does not match the workflow (repository name, branch or environment). Fix the credential. |
AuthorizationFailed on the deploy step | The app registration has no Website Contributor role on that Web App. |
The Azure Static Web Apps deployment token is invalid (or similar) | Read the token again with az staticwebapp secrets list and update the secret. A Static Web App that was deleted and created again has a new token. |
| The site job deploys an old build | The workflow artifact is the one from the check job of the same run. Look at that job’s Check step, not an earlier run. |
| A form posts but the API answers 404 on the live site | The linked backend is missing. Deploy the infrastructure again and look at linkedBackend in the output. |
| The workflow does not start | The file is not on the default branch, or the push was not to main. |
What’s next
- Azure DevOps if you use that instead.
- The lake in CI, and caches.
- Troubleshooting.
