GitHub Workflows Fundamentals

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 workflow
  • jobs, 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.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment