Skip to content
mdlake

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

Bash
npx mdlake hosting init --ci github --name acme-site

Add 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:

YAML
# 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

PartSetting
NameDeploy
TriggersA push to main, and workflow_dispatch (the Run workflow button)
ConcurrencyGroup deploy, cancel-in-progress: false. A second run waits for the first.
Permissionscontents: read. The server and split models add id-token: write for the keyless sign-in.
Runnerubuntu-latest

Pull requests do not trigger a deployment. The workflow has no pull request job.

The check steps are the same on every model:

  1. actions/checkout@v4.
  2. actions/setup-node@v4 with Node 22 and the npm cache. On a split .NET site, actions/setup-dotnet@v4 with 10.0.x as well.
  3. A step that runs git --version, so the log shows that git is there. The lake is fetched with git.
  4. npm ci.
  5. actions/cache@v4 for .mdlake/lake, node_modules/.astro/assets and node_modules/.cache/mdlake-assets, keyed on lake.lock. See The lake in CI, and caches.
  6. npx playwright install --with-deps chromium, for the accessibility check.
  7. npm run check, with MDLAKE_LAKE_TOKEN, SITE_URL and PUBLIC_TURNSTILE_SITE_KEY in 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:

YAML
      - 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: true

The 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:

  1. Assemble the package. The server needs its production dependencies next to the build:

    Bash
    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
  2. Sign in with azure/login@v2 using AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID.

  3. Deploy with azure/webapps-deploy@v3: app-name from the variable AZURE_WEBAPP_NAME, package: deploy, and startup-command: node dist/server/entry.mjs.

The split model

Three jobs run in sequence: check, then api, then site.

JobNeedsWhat it does
checknoneThe check steps. It then uploads dist as the workflow artifact site (actions/upload-artifact@v4).
apicheckBuilds the backend, signs in to Azure and deploys the API to its Web App (vars.AZURE_API_APP_NAME).
siteapiDownloads 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.

RuntimeBuild and package
.NETactions/setup-dotnet@v4 (10.0.x), then dotnet publish api/Api.csproj -c Release -o api-publish. The package is api-publish.
Nodeactions/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.

NameKindStaticServerSplitMeaning
MDLAKE_LAKE_TOKENsecretyesyesyesRead-only token for the lake repository. See The lake token.
SITE_URLvariableyesyesyesThe public address, for example https://www.acme.com.
PUBLIC_TURNSTILE_SITE_KEYvariablewhen usedwhen usedwhen usedThe public Turnstile site key. It ends up in the page, so it is not secret. See Turnstile.
AZURE_STATIC_WEB_APPS_API_TOKENsecretyesyesThe Static Web App deployment token. See Get the deployment token.
AZURE_CLIENT_IDsecretyesyesThe app registration’s client id.
AZURE_TENANT_IDsecretyesyesYour Microsoft Entra tenant id.
AZURE_SUBSCRIPTION_IDsecretyesyesThe subscription id.
AZURE_WEBAPP_NAMEvariableyesThe Web App name (the name output).
AZURE_API_APP_NAMEvariableyesThe API Web App, <name>-api (the apiName output).

With the GitHub CLI you can set them from a terminal:

Bash
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:

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.

  1. Create an app registration and its service principal.

    Bash
    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.

  2. Add a federated credential for the repository and the main branch. The subject starts with the prefix GitHub presents for your repository, and that prefix can hold numeric IDs, so read it first:

    Bash
    gh api repos/<owner>/<repository>/actions/oidc/customization/sub --jq .sub_claim_prefix

    It prints something like repo:acme@1234567/acme-site@987654321. Save this as credential.json, with what it printed in place of <prefix>:

    JSON
    {
      "name": "github-main",
      "issuer": "https://token.actions.githubusercontent.com",
      "subject": "<prefix>:ref:refs/heads/main",
      "audiences": ["api://AzureADTokenExchange"]
    }
    Bash
    az ad app federated-credential create --id <appId> --parameters credential.json

    The 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.

  3. Give it the role on the Web App it deploys to: Website Contributor.

    Bash
    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.

  4. Set the three secrets AZURE_CLIENT_ID, AZURE_TENANT_ID (az account show --query tenantId -o tsv) and AZURE_SUBSCRIPTION_ID (az account show --query id -o tsv).

The static model needs none of this.

The first run

  1. Push to main, or open Actions, choose Deploy and select Run workflow.
  2. Watch the check job. On the first run it fetches the lake and builds every image, so it takes the longest.
  3. 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:

ChangeHow
Deploy from another branch or on a tagEdit on: in the file, then npx mdlake adopt .github/workflows/deploy.yml.
Add a staging deploymentCopy 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 approvalAdd environment: production to the deploying job and require reviewers on that environment. Change the federated credential subject to match.
Pull request previewsNot generated. Static Web Apps supports them, but the split model’s linked backend does not work in pull request environments.

Problems

SymptomFix
npm run check fails with a lake errorThe token is missing, expired or too narrow. See The lake in CI, and caches.
Login failed ... AADSTS70025 or No matching federated identity record foundThe credential’s subject does not match the workflow (repository name, branch or environment). Fix the credential.
AuthorizationFailed on the deploy stepThe 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 buildThe 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 siteThe linked backend is missing. Deploy the infrastructure again and look at linkedBackend in the output.
The workflow does not startThe file is not on the default branch, or the push was not to main.

What’s next