Purpose of this page
Be able to read any workflow file confidently — knowing what triggers it, what order things happen in, where values come from, and why a job was skipped.
The anatomy of a workflow file the skeleton
Every workflow has the same handful of top-level keys. This skeleton isn't from this project — it's the smallest thing that shows each key in place.
.github/workflows/example.ymlname: A descriptive name shown in the Actions tab
on: # what triggers this workflow
workflow_dispatch:
env: # variables available to every job and step
SOME_SETTING: a-value
permissions: # what the workflow is allowed to do
contents: read
jobs:
a-job-id: # your choice of id
runs-on: ubuntu-latest # which machine to run on
steps:
- uses: actions/checkout@v6 # run a prebuilt action
- name: Say something
run: echo "hello" # run a shell command
- Workflow — one YAML file. Triggered as a whole.
- Job — a unit that gets its own fresh virtual machine. Jobs in a workflow run in parallel unless you say otherwise, and they share nothing by default, not even files.
- Step — one command or one action, run in order inside a job, all on the same machine.
“Jobs get a fresh machine” explains a lot of otherwise-surprising behavior: each job has to check out the repository again, re-install tools again, and log in to Azure again.
env: can appear at the top of the file (every job sees it), on a job (only that job), or on a single step. The project's deploy workflows put resource names at the top level, because every job needs them.
Triggers: when it runs the on: block
The on: key lists the events that start the workflow. A workflow can list several, and the event that fired is available to conditions later in the file.
| Trigger | Fires when |
|---|---|
push | Commits land on a branch — including when a pull request is merged, since merging moves the target branch. |
pull_request | A PR is opened or updated. Runs against a simulated merge of the PR into its target, so it tests the result rather than the branch alone. |
workflow_dispatch | Someone clicks Run workflow in the Actions tab. Can declare typed inputs: to produce a small form. |
schedule | On a cron timer. Not used in this project. |
Adding a paths: list under push or pull_request means the workflow only runs when the commit touches matching files. This is how the project avoids rebuilding the UI image because somebody edited a Terraform file.
Path filters apply to the whole workflow, not to individual jobs. That single limitation is why this project has separate deploy-api.yml and deploy-ui.yml files rather than one file with two jobs.
Inputs become fields in the Run workflow dialog and are readable as inputs.<name>. The project's Terraform workflow uses a choice input to pick between plan, apply, and destroy — one workflow, three deliberate operations.
A workflow_dispatch trigger has to exist on the repository's default branch before the Run workflow button appears at all.
Jobs, runners, and ordering who runs what, where
runs-on: ubuntu-latest asks GitHub for a fresh, managed Linux machine. It comes with a lot preinstalled — Git, Docker, Python, and notably the Azure CLI, which is why this project's workflows can call az without installing anything.
needs: test on a job means “don't start until the test job has finished successfully.” If the dependency fails, the dependent job doesn't run at all. Chaining needs is how the project guarantees nothing is deployed before its tests pass.
A job can declare services: — extra containers started alongside it, reachable on localhost. The API's test job starts a real postgres:16 container this way, loads the schema into it, and runs the test suite against it. Real database, thrown away when the job ends.
Names a lane that only one run may occupy at a time. The Terraform workflow uses it with cancel-in-progress: false, so two infrastructure runs can never overlap — and an in-flight apply is never cancelled partway through, which could leave real resources half-created.
Steps: uses vs. run two kinds of step
Pulls in a reusable action published by GitHub or a vendor, pinned to a version with @v6. Parameters go in a with: block. The ones this project relies on are actions/checkout (clone the repo into the runner), actions/setup-python, hashicorp/setup-terraform, and azure/login.
Nothing is checked out automatically — a job that needs repository files must start with actions/checkout. Jobs that only call az against existing resources don't need it at all.
Runs a command on the runner, using bash on Linux. A multi-line block with | runs as one script, so variables set on one line are visible on the next. working-directory: changes where it runs from — which the UI workflow uses so its build runs inside metals_ui.
Echoing specially-formatted lines lets a script control the run: ::error::… marks the run as failed with a message attached, and appending to $GITHUB_STEP_SUMMARY writes Markdown onto the run's summary page. The Terraform workflow uses the second to print the values you need to copy into GitHub secrets.
Conditions that skip work if:
An if: on a job or step decides whether it runs at all. A skipped job shows as skipped rather than failed, and anything depending on it is skipped too — which is exactly how this project keeps deployments out of pull requests.
| Condition | Means |
|---|---|
github.event_name == 'workflow_dispatch' | Only when a person clicked Run workflow — never on a push or PR. |
github.ref == 'refs/heads/main' | Only for activity on main. Combined with event_name == 'push', this is “a merge landed.” |
always() | Run even if earlier steps failed — used for cleanup that must happen regardless, like removing a temporary firewall rule. |
steps.<id>.outcome == 'success' | Branch on whether a specific earlier step worked, which requires giving that step an id:. |
Secrets, variables, and environments where values come from
Workflows read two kinds of stored configuration, and they behave differently: secrets are encrypted, write-only, and masked in logs; vars are plain text you can read back in the UI. Both can be scoped to the whole repository or to a named environment.
Declaring environment: Development on a job does three things: it gives that job access to that environment's secrets, it applies any protection rules configured on it (required reviewers, wait timers, allowed branches), and it records the deployment in the repository's Environments view with a link to the deployed URL.
This project uses two environments: Development for application deployments and Terraform for infrastructure, each holding its own distinct set of credentials.
If the same secret name exists at both repository and environment level, a job that declares the environment gets the environment's value. That's useful — but it means a stale repository-level secret can sit unnoticed for a long time, and will be picked up by any job that forgets to declare an environment.
Actions replaces secret values with *** in logs, and that's genuinely useful — it's why Azure error messages in this project's run logs show client '***'. But masking is pattern matching on output, not a sandbox: a workflow step can do anything it likes with a secret it's been handed. Keep secrets scoped narrowly and prefer short-lived tokens, which is precisely what the OIDC login on the next pages achieves.
Permissions and tokens what a run may do
Every run receives an automatic, short-lived GITHUB_TOKEN for acting on the repository itself. The permissions: block narrows — or, for one specific scope, widens — what that token allows.
| Setting | Why it appears in this project |
|---|---|
contents: read | The default floor: clone the repository, change nothing. Declared at the top of all three workflows. |
id-token: write | Permission to request an OIDC token — the short-lived, signed proof of identity that azure/login exchanges for Azure access. Without it, passwordless login fails; it's granted only on the jobs that actually talk to Azure. |
contents: none | Used on deploy jobs that never touch repository files, only existing Azure resources. Least privilege, stated explicitly. |