TutorialsDeployment › How GitHub Actions Works

Deployment · Page 1 of 3

Workflows, Jobs, and Triggers

GitHub Actions runs commands on GitHub's machines in response to things happening in your repository. Everything it does is described in YAML files under .github/workflows/, and the vocabulary is small: a workflow contains jobs, a job runs steps on a runner, and something has to trigger it. This page covers those pieces generically; the next page reads this project's three real workflows.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Files live in → .github/workflows/ Format → YAML Jobs run → in parallel by default Each job gets → a fresh machine

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.

Codey typing on a laptop
one file, top to bottom

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
The three nested levelsworth keeping straight
  • 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 at three levelsscoping

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.

TriggerFires when
pushCommits land on a branch — including when a pull request is merged, since merging moves the target branch.
pull_requestA 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_dispatchSomeone clicks Run workflow in the Actions tab. Can declare typed inputs: to produce a small form.
scheduleOn a cron timer. Not used in this project.
paths: filtersonly when relevant files change

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.

workflow_dispatch inputsa form for humans

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.

Codey pointing to the right
parallel unless told otherwise
needs:turning parallel into sequential

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.

services:a database for the tests

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.

concurrency:preventing overlap

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

uses:a prebuilt action

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.

run:a shell command

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.

Talking back to Actionsworkflow commands

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.

Codey holding up a sticky note
skipped is not failed
ConditionMeans
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:.

The project's actual rule, which appears on the deploying jobs of both app workflows, is the combination: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') — deploy when asked explicitly, or when a merge reaches main, and never otherwise.

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.

Codey holding a magnifying glass
scoped, masked, never printable
environment:more than a label

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.

Environment secrets wina precedence rule worth knowing

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.

Masking isn't protectionthe honest caveat

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.

SettingWhy it appears in this project
contents: readThe default floor: clone the repository, change nothing. Declared at the top of all three workflows.
id-token: writePermission 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: noneUsed on deploy jobs that never touch repository files, only existing Azure resources. Least privilege, stated explicitly.

id-token: write sounds alarming and isn't — it doesn't grant write access to anything. It permits the run to ask GitHub for a token that describes it (this repo, this branch, this environment). What that token can then unlock depends entirely on what Azure has been configured to trust, which is the next-but-one page.

Codey giving a thumbs up

That's the vocabulary — next, the three real workflow files that use it.