Table of Contents
If you’re anything like me, you’ve copy-pasted more GitHub workflows than you care to admit. It works, until it doesn’t. And when something breaks, you realise you don’t actually know what half of it does. This post is my attempt to fix that.
Rather than rehashing the documentation, I want to break down the anatomy of a workflow in a way that actually makes sense, particularly if you’re coming from an Azure DevOps background. In next week’s post we’ll take this further and look at what makes a great workflow specifically for platform engineers deploying Terraform to Azure.
What are GitHub Workflows?
A workflow is a set of instructions that run to perform a series of actions and produce an output. Both Azure DevOps Pipelines and GitHub Workflows use YAML, a plain text format that structures data using whitespace and indentation. Workflows are typically triggered automatically by events in the repository, but they can also be triggered manually on demand.
One structural difference worth knowing upfront: in Azure DevOps, your pipeline file can live anywhere in the repo. In GitHub Actions, workflows always live in the .github/workflows/ directory. Any YAML file in that directory is treated as a workflow.
GitHub Actions
One thing worth calling out before we go further, people often use the term GitHub Actions when they actually mean GitHub Workflows. GitHub Actions is the name of the platform feature itself. A GitHub Action (singular) is a reusable pre-built task that you call from the GitHub Marketplace within your workflow, the equivalent of an ADO task. The two terms get used interchangeably all the time, but they mean different things. Throughout this post, when I say workflow I mean the YAML file, and when I say Action I mean a reusable task called with uses:
Azure DevOps Pipeline vs GitHub Workflow Terminology
If you’re already familiar with ADO Pipelines, you’ll find GitHub Workflows fairly intuitive, but there are some naming differences and a few genuine structural differences that can catch you out. The table below maps the two so you’ve got a reference point as we go through the rest of the post. For reference, I wrote about Azure DevOps pipelines previously here.
| Azure DevOps Pipelines | GitHub Workflows | Notes |
|---|---|---|
| Pipeline | Workflow |
The top-level YAML definition file. In ADO the filename is flexible; in GitHub it must live in .github/workflows/.
|
| Stage | No equivalent |
A logical grouping of jobs in ADO. GitHub Workflows have no stage concept, the structure is flatter. You replicate sequencing with needs and approvals with Environments.
|
| Job | Job | The unit of execution. Runs on the agent or runner. In ADO, jobs sit within a stage; in GitHub, jobs sit directly at the workflow level. |
| Step | Step | The individual unit of work within a job, a script or an action. Identical concept in both. |
| Task | Action | Reusable building blocks used in steps. ADO tasks come from the ADO Marketplace; GitHub Actions come from the GitHub Marketplace. |
| Agent Pool | Runner | The compute that executes the job. |
| Service Connection | Secret / OIDC | How the pipeline authenticates with downstream resources such as Azure. |
| Variable Groups | Environment Secrets / Variables | Shared variables scoped to a project or environment. ADO variable groups can link natively to Key Vault; GitHub requires a separate Action to pull from Azure Key Vault. |
| Environments (approvals) | Environments | Both support required reviewer approvals before a deployment proceeds. In ADO approvals sit at the stage boundary; in GitHub the approval is on the job via the assigned Environment. |
| Templates | Reusable Workflows |
The mechanism for sharing pipeline code across repos. ADO uses template references; GitHub uses workflow_call. Covered in a later post in this series.
|
| Parameters | Workflow Dispatch Inputs | Manual trigger inputs that appear in the UI when triggering a workflow. ADO parameters work at both compile time and runtime; GitHub inputs are runtime only. |
| Triggers |
on
|
What fires the pipeline. GitHub’s on block is more expressive, you can combine multiple trigger types cleanly and filter on branches, paths, and tags.
|
The Anatomy of a GitHub Workflow
Every GitHub workflow has two required top-level keys:
on, defines what triggers the workflowjobs, defines what the workflow actually does
Within each job, runs-on and steps are required. Everything else builds on top of these foundations.
On, Defining Your Triggers
The on block tells GitHub what event should fire the workflow. Think of it as the entry point. There are four triggers you’ll use most regularly.
push
Fires when a commit is pushed to the repository. You can filter by branch and by file path, useful for only triggering infrastructure workflows when Terraform files actually change. If you’re used to ADO, you may be familiar with adding trigger: none to disable push triggers. You don’t need to do that in GitHub, simply don’t include push in your on block.
on:
push:
branches:
- main
- 'feature/**'
paths:
- 'terraform/**'
- '!terraform/docs/**' # exclude docs changes
pull_request
Fires when a pull request is opened or updated against a target branch. GitHub gives you more granular control here than ADO, you can specify exactly which PR events should trigger the workflow using types. The default behaviour (if you don’t specify types) is to fire on opened and synchronize, which covers most use cases.
One important gotcha: workflows triggered by pull_request from a forked repository run with read-only permissions and no access to secrets. This is a security measure GitHub applies automatically, and it will catch you out if you’re used to secrets being available in PR pipelines in ADO.
on:
pull_request:
branches:
- main
types:
- opened
- synchronize
paths:
- 'terraform/**'
workflow_dispatch
A manual trigger. When defined, a “Run workflow” button appears in the GitHub Actions UI, and any inputs you define are rendered as a form. This is useful for workflows where you want a human to make a deliberate choice before anything runs, selecting a target environment before a deployment, for example.
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- dev
- staging
- prod
dry_run:
description: 'Plan only, no apply'
type: boolean
default: false
schedule
Fires the workflow automatically based on a cron expression. A couple of things worth knowing: scheduled workflows only run from the default branch, if you define a schedule on a feature branch, it simply won’t fire. GitHub can also delay or skip scheduled runs during periods of high load, so don’t rely on this for anything time-critical.
on:
schedule:
- cron: '0 6 * * 1-5' # 6am UTC, Monday to Friday
Combining Triggers
In practice you’ll often want multiple triggers on a single workflow. GitHub handles this cleanly, all four can sit together in one on block:
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
schedule:
- cron: '0 6 * * 1-5'
Runners
Runners are the compute that workflow jobs run on, the equivalent of Azure DevOps Agent Pools. There are two types: GitHub-hosted runners managed by GitHub, and self-hosted runners that you manage yourself. I’ll cover self-hosted runners in more detail later in this series.
For GitHub-hosted runners, you have Linux, Windows, and macOS options available. GitHub maintains these images and publishes a full list of exactly what’s pre-installed on each one, including specific tool versions, in a public repository here. It’s worth bookmarking.
The runner is specified within the job using runs-on:
jobs:
deploy:
runs-on: ubuntu-latest # GitHub-hosted, latest Ubuntu
deploy-pinned:
runs-on: ubuntu-24.04 # pinned to a specific version
deploy-windows:
runs-on: windows-latest
deploy-self-hosted:
runs-on: [self-hosted, linux, azure] # self-hosted, matching all three labels
Most of the time you’ll use ubuntu-latest and that’s fine. But be aware, ubuntu-latest isn’t pinned to a specific version. GitHub moves that label forward when they release a newer image, which means the runner your workflow runs on today might be different in twelve months. If your workflow relies on a specific pre-installed tool version, pin to an explicit image label like ubuntu-24.04 instead.
Jobs and Steps
Jobs
Jobs are the unit of execution within a workflow. By default they run in parallel, if you define three jobs, GitHub spins up three runners and executes all three simultaneously. When you need jobs to run sequentially, use the needs keyword to define dependencies between them. In a Terraform workflow, for example, you’d want validate to complete before plan runs, and plan to complete before apply:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- run: echo "validating..."
plan:
runs-on: ubuntu-latest
needs: validate # won't start until validate completes successfully
steps:
- run: echo "planning..."
apply:
runs-on: ubuntu-latest
needs: plan # won't start until plan completes successfully
steps:
- run: echo "applying..."
Something that catches a lot of people out coming from ADO: each job runs on its own fresh runner. There is no shared filesystem between jobs. If your validate job checks out the code and runs terraform init, the plan job knows nothing about it, it starts completely clean. You can work around this by re-running setup steps in each job, using caching, or passing data between jobs using outputs and artefacts. We’ll cover that properly in the next post.
Steps
Steps are the individual units of work within a job. They run sequentially on the same runner, and they share the workspace, so a file created in step 1 is available in step 2.
There are two types of step. Use run when you’re executing a shell command directly:
steps:
- name: Install Terraform
run: |
wget -O terraform.zip https://releases.hashicorp.com/terraform/1.7.0/terraform_1.7.0_linux_amd64.zip
unzip terraform.zip
sudo mv terraform /usr/local/bin/
Use uses when you’re calling a pre-built Action from the GitHub Marketplace:
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
The with block is how you pass inputs into an Action, it’s the equivalent of configuring inputs on an ADO task. Always give your steps a name, it’s not required, but it’s what appears in the GitHub Actions UI against each step. Without it, you just see the raw command, which gets hard to read fast.
Contexts and Expressions
Contexts are how you access information about the workflow run and the environment it’s running in. The github context gives you details about the run itself, the branch, the commit SHA, who triggered it:
steps:
- name: Print branch name
run: echo "Running on branch ${{ github.ref_name }}"
- name: Print commit SHA
run: echo "Commit is ${{ github.sha }}"
- name: Print who triggered this
run: echo "Triggered by ${{ github.actor }}"
The vars context gives you access to non-sensitive configuration variables stored at repo, environment, or organisation level:
steps:
- name: Print default region
run: echo "Default region is ${{ vars.AZURE_REGION }}"
The ${{ }} syntax is the expression syntax. Anything between the curly braces is evaluated by GitHub before the workflow hits the runner, GitHub processes the YAML, resolves all expressions, and substitutes the actual values in. By the time the runner sees the workflow, all the expressions are already replaced with literal strings.
So if your workflow contains:
- run: echo "Triggered by ${{ github.actor }}"
What the runner actually receives and executes is:
echo "Triggered by denis"
The expression is gone. GitHub resolved it and baked the value in. The runner has no knowledge that ${{ github.actor }} was ever there.
You can also use expressions for conditional logic, either at the job level or the step level:
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
steps:
- run: echo "Deploying..."
- name: Apply
run: terraform apply
if: ${{ inputs.dry_run == false }}
Environment Variables and Secrets
Understanding how environment variables and secrets work, and why they’re handled differently, is important, so it’s worth spending a moment on it.
The secrets context gives you access to secrets stored in GitHub:
steps:
- name: Login to Azure
env:
CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p $CLIENT_SECRET
You’ll notice two things happening here. The client ID is substituted directly using ${{ secrets.AZURE_CLIENT_ID }}. The client secret is handled differently, it’s passed via env and referenced as a shell variable $CLIENT_SECRET. That’s deliberate, and here’s why.
When GitHub substitutes a ${{ secrets.MY_SECRET }} expression directly into a run command, the secret value gets baked into the shell command as a literal string. GitHub masks it in logs, but the value is embedded in the process arguments, which can be exposed by debugging tools, process inspection, or error output in ways that log masking doesn’t catch.
When you pass a secret via env, GitHub still evaluates the expression before the runner executes, but instead of baking the value into the command string, it hands it to the runner as an environment variable. The shell resolves $CLIENT_SECRET at execution time. The secret value never appears as a literal in the command itself. Operating systems also handle environment variables more carefully than command line arguments, they’re not visible in process listings the same way.
The practical rule: non-sensitive context values can be interpolated directly. Secrets should always go via env.
Putting It All Together
Here’s a complete workflow that pulls everything we’ve covered into one place, a Terraform validate and plan workflow that would be a reasonable starting point for any platform engineer:
on:
push:
branches:
- main
paths:
- 'terraform/**'
pull_request:
branches:
- main
types:
- opened
- synchronize
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- dev
- staging
- prod
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
plan:
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
run: terraform init
- name: Terraform Plan
env:
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: terraform plan -var="environment=${{ inputs.environment }}"
This isn’t production-ready, we haven’t wired up OIDC authentication, added approvals, or dealt with state, but it illustrates how all the pieces connect. That’s exactly what we’ll build out in the next post.
Summary
We’ve covered the core anatomy of a GitHub workflow, triggers, runners, jobs, steps, contexts, expressions, and how secrets should be handled. If you’re coming from Azure DevOps, most of this will feel familiar with a few structural differences worth keeping in mind: there are no stages, jobs don’t share a filesystem, and the on block is your friend once you understand what each trigger does.
Next week we’re building a production-ready Terraform deployment workflow from scratch, OIDC authentication, environment approvals, and a plan that gets commented back to your pull request.
Drop your email below if you want the one-page GitHub Workflow quick reference guide.