Purpose of this page
Know which workflow runs when, what each job does, and why the API and UI have separate files rather than one shared pipeline.
The three files overview
| File | What it does, and what wakes it up |
|---|---|
| deploy-api.yml | Tests the API, then builds, pushes, and deploys the metals-api image. Triggered by changes under metals_api/, sql/, or the requirements files. |
| deploy-ui.yml | Builds, pushes, and deploys the metals-ui image. Triggered by changes under metals_ui/. |
| terraform.yml | Validates the Terraform configuration on every change, and runs plan, apply, or destroy only when a person asks. |
The rule that decides what runs the important part
Both application workflows trigger on pushes, pull requests, and manual dispatch — but the jobs that touch Azure carry an if: condition, so a trigger alone isn't enough to deploy anything.
the condition on every Azure-touching jobif: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
| What you did | deploy-api.yml | deploy-ui.yml | terraform.yml |
|---|---|---|---|
| Opened or updated a PR | Tests only | Local image build only | fmt, validate, test |
| Pushed to a feature branch | Tests only | Local image build only | fmt, validate, test |
| Merged a PR to main | Test → build → deploy | Build → deploy | fmt, validate, test |
| Clicked Run workflow | Test → build → deploy | Build → deploy | plan / apply / destroy, as selected |
deploy-api.yml three jobs
A straight chain: test → build-and-push → deploy, each job waiting on the one before it via needs:.
Starts a postgres:16 service container, loads sql/metals-db.sql into it with psql, then runs pytest metals_api/tests against that real database. This is the only job with no if: — it always runs, which is the point: every push and every PR gets tested.
Logs in to Azure with azure/login, then runs a single command:
az acr build --registry "$REGISTRY_NAME" --resource-group "$RESOURCE_GROUP" \ --image "metals-api:$IMAGE_TAG" --file metals_api/Dockerfile .
The build happens on Azure's own build agents, not the GitHub runner — the runner only uploads the source. That's why no Docker setup step is needed, and why the image never travels across the internet twice.
Points the Web App at the image, restarts it, then polls /health up to ten times with a ten-second wait, failing the run with ::error:: if it never returns 200. Its environment: block includes a url:, so the deployed address is linked directly from the run summary and the repository's Environments view.
It declares contents: none and skips actions/checkout entirely — this job never needs repository files, only the Azure CLI and its OIDC token.
deploy-ui.yml, and why it's separate a structural decision
The obvious design is one workflow with an API job and a UI job. It doesn't work, for a specific reason: paths: filters apply to the entire workflow, not to individual jobs. One shared file would mean every UI tweak also rebuilt and redeployed the API, and vice versa.
Editing a stylesheet under metals_ui/ triggers only the UI workflow; changing a route under metals_api/ triggers only the API's. Each app ships on its own schedule, and an unrelated change can't cause an unnecessary restart of the other.
The UI has no automated test suite, so on pushes and PRs its build job runs a plain docker build on the runner — no registry, no Azure login, no credentials involved. It proves the image still builds, which is the useful signal available here.
That validation job is deliberately a separate job from build-and-push rather than one job with conditional steps, because environment: is a job-level setting — keeping them apart means an ordinary PR never touches the Development environment or its protection rules.
The UI's az acr build step sets its working directory before running. That's not cosmetic: az acr build validates the --file path against the current directory rather than the source argument, so running it from the repository root with --file Dockerfile metals_ui fails with Unable to find 'Dockerfile'. The same workaround appears in the manual deploy script.
terraform.yml infrastructure, manually
Two jobs: one that always validates, and one that only runs on workflow_dispatch and performs the operation you picked from a dropdown.
Runs fmt -check, init -backend=false, validate, and test for both the main configuration and the bootstrap module, then the Python unit tests for the schema loader. Everything here is offline — it never authenticates to Azure and can never change a resource.
Guarded by if: github.event_name == 'workflow_dispatch'. It checks that every required secret and variable is actually set before doing anything, connects Terraform to its remote state in Azure Blob Storage, produces a plan, and applies that same plan in the same run if the operation wasn't plan.
The operation defaults to plan, so the least destructive choice is the one you get by clicking through without thinking.
To load the database schema after an apply, the runner must reach PostgreSQL — but GitHub's runners have unpredictable IP addresses. The workflow looks up its own public IP, adds a firewall rule named after the run, initializes the database, and removes the rule in a cleanup step marked if: always() so it runs even when the initialization failed.
After an apply it appends the Terraform outputs to $GITHUB_STEP_SUMMARY — the client/tenant/subscription IDs for the application deployment identity, both Web App names, and the registry login server. Those are the values that go into the Development environment's secrets, covered on the next page.
Running them in practice
Day to day you don't run these at all — you merge a pull request and the right one fires. Manual dispatch is for first-time setup, infrastructure changes, and redeploying without a new commit.
| # | Goal | How |
|---|---|---|
| 1 | Ship an API change | Merge it to main. Tests run, then the image builds and deploys automatically. |
| 2 | Redeploy without changing code | Actions → Build and deploy metals-api to Azure → Run workflow. |
| 3 | Create or update Azure resources | Actions → Terraform infrastructure → Run workflow, operation apply. |
| 4 | Preview an infrastructure change | Same, operation plan — reads state and Azure, changes nothing. |
| 5 | Tear the environment down | Same, operation destroy. Deletes the database and all its data. |
Common errors and how to fix them
| What you see | Likely cause | How to fix it |
|---|---|---|
| Tenant '***' not found during azure/login | A malformed or empty AZURE_TENANT_ID — usually a copy-paste slip when the secret was added. | Re-enter the three AZURE_* secrets on the Development environment. See the next page. |
| AuthorizationFailed on a registry action | The deployment identity is missing a role, or one was granted moments ago and hasn't propagated. | Check the roles listed on the next page; allow a few minutes after any role change. |
| Unable to find 'Dockerfile' during az acr build | The step ran from a directory where that relative path doesn't exist. | Ensure the UI step keeps its working-directory: metals_ui. |
| Workflow didn't run at all after a push | The commit didn't touch any path in that workflow's paths: filter. | Expected behavior. Use Run workflow to deploy anyway. |
| Deploy jobs show as skipped on a PR | The if: gate — PRs validate but never deploy. | Also expected. Merge to main, or dispatch manually. |
| Deploy succeeded but the site is unchanged | A new image was pushed under the same tag and nothing restarted. | Confirm the restart step ran; see why the restart exists. |