GitHub Actions Deep Dive
Why this matters
Track 07's module 08 showed you exactly one workflow: a single job with a handful of steps that built an image and deployed it. That's enough to ship, but it's the "hello world" of GitHub Actions — it uses almost none of the machinery that makes Actions worth learning as a real tool. This module takes the whole model apart: the workflow/job/step hierarchy, runners, expressions and contexts, matrix builds that fan one job into many, reusable workflows and composite actions that kill copy-paste across repos, and the secrets/environments system that later modules lean on for security. Everything else in this track is written in this language; this module is where you actually learn to read and write it fluently.
Concepts
The hierarchy: workflow → job → step, and where each runs
A workflow is a single YAML file in .github/workflows/. It declares
when it runs (on:) and what it runs (jobs:). A job is a named
unit of work that runs on a fresh runner — a clean VM (or container)
that GitHub spins up, hands to your job, and destroys afterward. A step
is one action inside a job: either a shell command (run:) or a reusable
action (uses:).
The critical mental model, sharper than track 07's single-job example implied:
- Steps in one job share a filesystem and run in order on the same runner. A file one step writes, the next step can read.
- Jobs run on separate runners and are isolated by default — they run
in parallel unless you declare a dependency with
needs:, and they do not share a filesystem. To pass data between jobs you must explicitly upload/download artifacts (theactions/upload-artifact/download-artifactactions) or use job outputs.
This is why a naive multi-job workflow that "builds in job A and deploys in job B" fails if you forget that job B starts on a blank machine — job A's built files aren't there unless you carried them over.
Runners: GitHub-hosted vs. self-hosted
runs-on: picks the runner. GitHub-hosted runners (ubuntu-latest,
windows-latest, macos-latest) are ephemeral VMs GitHub provisions,
pre-loaded with common tools (Docker, az, kubectl, language runtimes),
billed per-minute on private repos and free on public ones. Self-hosted
runners are machines you register and run the agent on — used when you
need private-network access (e.g. a runner inside your Azure VNet that can
reach a private AKS API server from track 07), specialized hardware, or to
avoid per-minute billing at scale. GitHub-hosted is the default and what
this track uses; know self-hosted exists because "the runner can't reach
the private cluster" is a real production reason to switch.
Expressions, contexts, and ${{ }}
GitHub Actions has a small expression language evaluated inside ${{ }}.
It reads from contexts — structured objects describing the run:
github— the event and repo:github.sha(commit SHA — the tag from track 07's module 08),github.ref(refs/heads/main),github.event_name(push,pull_request),github.actor.secrets— repository/environment secrets:secrets.AZURE_CLIENT_ID.env— variables you defined withenv:.matrix— the current matrix combination (below).needs— outputs of jobs this job depends on.steps— outputs of earlier steps in the same job (viaid:).
Expressions drive conditionals with if: — e.g.
if: github.ref == 'refs/heads/main' runs a deploy step only on main,
the clean way to keep PR builds from deploying (the trigger-scoping concern
from module 00 and track 07's module 08, now done inside the workflow
instead of only at the trigger).
Matrix builds: one job definition, many parallel runs
A matrix expands a single job into multiple parallel runs, one per combination of the variables you list. Instead of copy-pasting a "test on Node 18 / test on Node 20 / test on Node 22" job three times:
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
That produces 6 jobs (3 × 2), each with matrix.node and matrix.os
set, all running in parallel. fail-fast: true (the default) cancels the
rest the moment one fails — the fail-fast idea from module 00, at the job
level. Matrix is how you test across versions/platforms without
duplication, and it ties directly to module 02's test stage.
Reusable workflows and composite actions: DRY across pipelines
Two mechanisms remove duplication, and they're often confused:
- A reusable workflow is an entire workflow file callable from another
workflow via
uses: owner/repo/.github/workflows/build.yml@mainwithon: workflow_call. It's a whole job graph you invoke — good for "every repo's deploy pipeline is the same 4 jobs." Inputs and secrets are passed explicitly. - A composite action bundles several steps into one reusable
uses:step (anaction.ymlwithruns: using: composite). It's a reusable step sequence, not a whole workflow — good for "the 3 steps to log in to Azure and set AKS context" that you'd otherwise paste into every job.
Rule of thumb: repeating a set of steps → composite action; repeating a whole pipeline shape across repos → reusable workflow. Both are how a platform team gives every app repo a consistent pipeline (a theme that returns in track 24, platform engineering).
Secrets and environments: scoped, masked, gated
Secrets are encrypted values (secrets.FOO) injected at runtime and
masked in logs (GitHub redacts them if they appear in output). They
live at three scopes: organization, repository, and environment.
An environment (module 00's concept, now concrete) is a repo-level
named target (staging, production) that can carry its own secrets and
protection rules — required reviewers (a human must approve before a
job targeting that environment runs), wait timers, and branch
restrictions. A job opts into an environment with environment: production;
GitHub then pauses that job for approval if the environment requires it.
This is the exact machinery that turns continuous deployment into
continuous delivery for production — module 07 uses it in depth, and
module 07 also replaces long-lived secrets with OIDC (the pattern from
track 07's module 08, generalized).
Permissions and the GITHUB_TOKEN
Every workflow run gets an automatically-provisioned GITHUB_TOKEN with
permissions scoped by the permissions: block. Track 07's module 08
already showed one required permission — id-token: write for OIDC. The
principle is least privilege: grant only what the job needs
(contents: read to check out code, packages: write to push to GHCR,
id-token: write for OIDC). An over-permissive token is a real blast-radius
risk if a step runs untrusted code — a security theme module 07 develops.
Command reference
Workflow YAML explained key-by-key, plus the gh CLI you'll use to drive
runs from the terminal.
| Key / command | What it does | Notes |
|---|---|---|
on: |
Declares triggers | push, pull_request, workflow_dispatch, workflow_call, schedule (module 00) |
on.push.branches: [main] |
Filter: only pushes to main |
Scopes the trigger to avoid deploying from every branch |
on.pull_request.paths: |
Filter: only when matching files change | e.g. only run when src/** changes, saving CI minutes |
jobs.<id>.runs-on: |
Picks the runner | ubuntu-latest (hosted) or self-hosted (your machine) |
jobs.<id>.needs: [build] |
Declares job dependency (ordering) | Without it, jobs run in parallel and don't share a filesystem |
jobs.<id>.strategy.matrix: |
Fans one job into N parallel runs | Combinations of the listed variables |
strategy.fail-fast: false |
Don't cancel siblings when one matrix leg fails | Default is true |
jobs.<id>.environment: |
Targets a protected environment | Triggers required-reviewer gate if configured (module 07) |
jobs.<id>.permissions: |
Scopes the GITHUB_TOKEN |
Least privilege; id-token: write needed for OIDC |
steps[].uses: |
Runs a reusable action | actions/checkout@v4, azure/login@v2, or a composite action |
steps[].run: |
Runs shell commands on the runner | Multi-line with | |
steps[].id: + steps[].outputs |
Names a step so later steps read its outputs | ${{ steps.<id>.outputs.<name> }} |
${{ github.sha }} |
Expression: the commit SHA | The image tag from track 07 module 08 |
${{ github.ref == 'refs/heads/main' }} |
Expression used in if: |
Gate a step/job to main only |
uses: owner/repo/.github/workflows/x.yml@ref |
Calls a reusable workflow | The called file needs on: workflow_call |
gh workflow list |
Lists workflows in the repo | Requires gh auth login first |
gh workflow run <file> -f key=val |
Manually triggers a workflow_dispatch run with inputs |
The CLI equivalent of the "Run workflow" button |
gh run list --workflow=<file> |
Lists recent runs of a workflow | Add --branch main to filter |
gh run watch <run-id> |
Streams a run's live status in the terminal | Good for watching a deploy without the browser |
gh run view <run-id> --log-failed |
Shows logs for only the failed steps | Fastest way to triage a red run |
Hands-on exercises
You'll need a GitHub repo you control and gh CLI installed
(gh auth login). Any small app repo works; if you have the demo app from
track 07, reuse it.
A two-job workflow and the isolation gotcha. Create
.github/workflows/two-jobs.ymlwith a jobbuildthat runsecho hello > out.txt && cat out.txt, and a second jobcheck(noneeds:) that runscat out.txt. Push it, watch it in the Actions tab (orgh run watch), and observe thatcheckfails —out.txtdoesn't exist on its runner. Now addneeds: [build]and anactions/upload-artifactstep inbuildplusdownload-artifactincheck. Re-run and confirmcheckpasses. This is the job-isolation concept made real.Contexts and conditionals. Add a workflow triggered on both
pushandpull_requestthat has one step printingecho "event=${{ github.event_name }} ref=${{ github.ref }}"and a second step guarded byif: github.ref == 'refs/heads/main'that printsecho "this only runs on main". Open a PR from a branch and confirm the guarded step is skipped on the PR run but runs after you merge tomain.A matrix build. Write a job with
strategy: { matrix: { version: [1, 2, 3] } }whose single step runsecho "building version ${{ matrix.version }}". Push and confirm the Actions tab shows three parallel jobs. Then add a second matrix dimension (os: [ubuntu-latest, windows-latest]) and confirm it becomes six jobs.Fail-fast behavior. In that matrix, make the step
exit 1only whenmatrix.version == 2(if [ "${{ matrix.version }}" = "2" ]; then exit 1; fi). Run with the default and observe siblings get cancelled. Addstrategy: { fail-fast: false }and confirm the other legs now run to completion despite version 2 failing. Note which behavior you'd want for a test matrix (module 02) and why.Step outputs and
needs. Make jobbuildproduce an output: a step withid: metarunningecho "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT", and expose it viaoutputs: { tag: ${{ steps.meta.outputs.tag }} }on the job. In a downstream job withneeds: [build], printecho "${{ needs.build.outputs.tag }}". Confirm the short SHA flows from one job to the next — the mechanism module 03 uses to pass an image tag from a build job to a deploy job.A composite action. Create
.github/actions/greet/action.ymlwithruns: { using: composite, steps: [...] }that takes an inputnameand runsecho "hello ${{ inputs.name }}". Use it from a workflow withuses: ./.github/actions/greetandwith: { name: world }. Confirm it works, then reflect: which of the repeated steps in track 07's module-08 workflow (Azure login + ACR login + set AKS context) would be a natural composite action?A reusable workflow. Create
.github/workflows/reusable-build.ymlwithon: workflow_calltaking an inputimage-name, and a caller workflow that invokes it viauses: ./.github/workflows/reusable-build.ymlwithwith: { image-name: demo }. Confirm the caller's run shows the reusable workflow's job nested inside it. Note the difference from exercise 6: a composite action is a step, a reusable workflow is a whole job.Drive it from the CLI. Add
workflow_dispatchwith an inputmessageto any workflow. Trigger it from the terminal withgh workflow run <file> -f message="from the cli", thengh run watchthe resulting run andgh run view <id> --logto read the output. This is how you operate pipelines without leaving the terminal.Diagnose and fix: the artifact-not-found failure. You'll deliberately reproduce and fix the single most common multi-job mistake. Take a workflow where job
deploy(withneeds: [build]) runscat dist/app.txt, and jobbuildcreatesdist/app.txtbut does not upload it as an artifact. Run it, watchdeployfail with "No such file or directory". Diagnose why (jobs don't share a filesystem — the concept from this module), then fix it by uploading the artifact inbuildand downloading it indeploy. Confirm the green run. Write one sentence on whyneeds:alone (ordering) was not enough — it guarantees order, not shared files.
Independent challenge
No YAML given — build this from the concepts, drawing on this module and
track 07's module 08. Refactor a single monolithic workflow into a clean,
reusable shape. Start from a workflow that does build-then-deploy as one
long job (like track 07's module 08). Split it so that: a matrix runs
the app's build/test across at least two variants (e.g. two language
versions) in parallel; the actual build-and-push logic lives in a
reusable workflow invoked with workflow_call so a second app repo
could call the identical pipeline; the repeated "authenticate and set
context" steps become a composite action used by the deploy job; the
deploy job only runs on push to main (guarded by an if: expression on
github.ref, not just the trigger) and targets a production
environment; and each job declares a minimal permissions: block rather
than inheriting broad defaults. You won't be able to fully wire the deploy
without the Azure pieces from later modules — stub those steps with
echo placeholders — but the structure (matrix, reusable workflow,
composite action, environment, scoped permissions, if-guarded deploy)
should all be real and runnable. This is the skeleton every later module
fills in.
Stuck? One hint
Build it in layers and get each layer green before adding the next, rather
than writing the whole thing and debugging a wall of YAML. Order: (1) get
a plain single job green; (2) add the matrix and confirm the fan-out; (3)
extract the build steps into a workflow_call file and call it; (4) pull
the auth steps into .github/actions/<name>/action.yml and reference it
with uses: ./...; (5) add the environment: and if: github.ref == 'refs/heads/main' guard last. The two easiest mistakes are forgetting
on: workflow_call in the reusable file (the caller errors that it can't
find a callable workflow) and forgetting that a composite action lives in
action.yml, not a workflow file.
Common mistakes & troubleshooting
- Expecting jobs to share a filesystem. Steps in a job share a disk;
separate jobs do not. Passing files between jobs needs
upload/download-artifact or job outputs —
needs:only orders them. - Forgetting
on: workflow_callin a reusable workflow. The caller fails to find a callable workflow. A reusable workflow must declareworkflow_call; a composite action must be anaction.ymlwithusing: composite— mixing the two is a frequent first-time error. - Confusing composite actions with reusable workflows. One packages
steps (a single
uses:step); the other packages jobs (a whole invoked workflow). Reaching for the wrong one leads to awkward YAML. - Trigger vs.
ifguard confusion.on: push: branches: [main]stops the whole workflow from running offmain. Anif:on a job/step lets the workflow run (e.g. on a PR) but skips just the deploy. Use the trigger to scope the workflow,if:to scope individual steps within it. - Assuming secrets are available everywhere. Environment-scoped secrets
only exist in jobs that declare that
environment:. A job without the environment can't read them and gets an empty value. - Over-broad
permissions:. Inheriting default write-all permissions is a blast-radius risk. Setpermissions:explicitly per job; add only what's needed (id-token: writefor OIDC,contents: readfor checkout). fail-fast: truemasking flaky matrix legs. With the default, one failing matrix leg cancels the others, so you might not see that three versions are broken, only the first. For a diagnostic test matrix,fail-fast: falseshows you the full picture.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What is the difference in filesystem and execution between two steps in the same job versus two jobs in the same workflow?
- What does
needs:guarantee, and what does it not guarantee? - A matrix has
node: [18, 20]andos: [ubuntu-latest, windows-latest]. How many jobs run, and what doesfail-fast: truedo when one fails? - When would you reach for a composite action versus a reusable workflow?
- What's the difference between scoping a deploy with
on: push: branches: [main]and scoping it withif: github.ref == 'refs/heads/main'? When would you use each? - What does a GitHub environment add on top of plain repository secrets, and how does that relate to continuous delivery vs. deployment (module 00)?
- Why might a team switch from GitHub-hosted to self-hosted runners for an AKS deploy, given what you know about private clusters from track 07?
- What is the
permissions:block for, and what's the risk of leaving it at the default?
Show answers
- Two steps in the same job run in order on the same runner and share its filesystem — one step's file is visible to the next. Two jobs run on separate runners, in parallel by default, and share nothing — files must be passed via artifacts or job outputs.
needs:guarantees ordering — the dependent job starts only after its dependency succeeds. It does not share files or state; the downstream job still starts on a blank runner.- 2 × 2 = 4 jobs.
fail-fast: true(the default) cancels the still-running sibling jobs as soon as any one leg fails. - Composite action when you're repeating a set of steps (e.g. the login sequence) within jobs; reusable workflow when you're repeating a whole pipeline shape (a job graph) across workflows or repos.
on: push: branches: [main]prevents the workflow from running at all except on pushes tomain.if: github.ref == 'refs/heads/main'lets the workflow run (e.g. on PRs, to run tests) but skips the specific guarded step/job unless onmain. Use the trigger to scope the whole workflow; useif:to run some steps everywhere and gate the deploy step tomain.- An environment adds environment-scoped secrets and protection rules — notably required reviewers, which pause a job until a human approves. That approval gate is exactly what turns automated (continuous deployment) into human-gated (continuous delivery) for production.
- A self-hosted runner can live inside your Azure VNet and reach a private AKS API server that a GitHub-hosted runner (on the public internet) cannot. Private-cluster access, specialized hardware, or avoiding per-minute billing are the usual reasons.
permissions:scopes the automatically-provisionedGITHUB_TOKENto least privilege. Left at the default (broad write access), a compromised or misbehaving step has a large blast radius — it could push code, packages, or releases it never needed to.
Next
02-building-and-testing-in-ci — use this machinery to run real tests, cache dependencies, and make a green test run a required gate before a PR can merge.