Phase 2 · CONTAINERS & CI/CD
GitHub Actions 1 — workflow anatomy, triggers
By the end of today
- Write a ci.yml workflow that runs on push and pull_request
- Explain name, on, jobs, runs-on and steps in one workflow file
- Read the Actions tab and get a run to a green check
A workflow file: events, jobs, and steps
For four weeks you’ve built and run containers by hand. CI/CD is where that stops being manual: you push code, and a machine you never touched builds, tests, and reports back — the same way, every time. GitHub Actions is GitHub’s built-in engine for that, and the whole thing is driven by one YAML file you commit to your repo.
That file lives at a fixed path: .github/workflows/ci.yml (any .yml file under .github/workflows/ works). GitHub watches that folder, and each file defines a workflow — an automated process that runs when something happens. Five keys make up the anatomy:
name:— a human label for the workflow, shown in the Actions tab. Optional, but worth setting.on:— the trigger: which events start this workflow. This is the heart of it (more below).jobs:— one or more named jobs. A workflow is a bag of jobs; today you write one.runs-on:— the machine each job gets.runs-on: ubuntu-latestasks GitHub for a fresh, throwaway Ubuntu VM — a GitHub-hosted runner — spun up just for this run and destroyed after.steps:— the ordered list inside a job. Each step eitheruses:a prebuilt action or **run:**s a shell command.
Steps come in two flavours. uses: actions/checkout@v4 pulls in a published, versioned action — here the official actions/checkout action, maintained by GitHub, which clones your repo onto the runner (nothing else can see your code until it runs). run: node build.js just executes a shell command on that Ubuntu box, exactly like your terminal.
The on: trigger — three you’ll use daily
on: decides when. Three cover almost everything early:
push— runs when commits land on a branch. Your safety net: every push is built.pull_request— runs against the merge of a PR before it lands. This is what produces the green check reviewers gate merges on.workflow_dispatch— adds a Run workflow button in the Actions tab so you can trigger it by hand.
List several and any one fires the workflow.
Real world: A workflow is a factory line that starts itself.
on:is the sensor at the door — a pallet arrives (a push) and the belt switches on.runs-on:is the empty workbench wheeled in fresh for this order;steps:are the stations it passes, in order; and the green check in the Actions tab is the QA stamp at the end. No stamp, no shipping. Nobody pressed start — the event did.
When a run finishes, the repo’s Actions tab shows it: a list of runs, each with a status dot — yellow while it works, a green check when every step exited 0, a red X the moment one step fails. Click a run, click a job, and you get the live log of every step’s output. That green check next to a commit is the single most-read signal in day-to-day engineering: it means the machine agrees your code builds.
One file, committed to the repo, turns “push code” into “code is automatically built and verified.” Everything else in this phase — matrices, secrets, pushing images — is just more keys in this same anatomy.
Hands-On Lab
Budget about 25 minutes. You need a GitHub account and the gh CLI authenticated once with gh auth login (it opens a browser and stores a token). Work in your WSL2 Ubuntu 24.04 terminal. Run IDs, commit SHAs, timings and ages are unique to each push and run — yours will differ from the samples below.
# 1. Create a repo with one trivial file to "build".
mkdir -p ~/gha-lab && cd ~/gha-lab
git init -q -b main
echo "console.log('build ok');" > build.js
git add . && git commit -q -m "Initial commit"
git log --oneline
# Output (your commit SHA will differ):
# 4c1a9e2 (HEAD -> main) Initial commit
# 2. GitHub only looks in one folder for workflows — create it.
mkdir -p .github/workflows
ls -R .github
# Output:
# .github:
# workflows
#
# .github/workflows:
Save the following as .github/workflows/ci.yml. This is the whole anatomy: a name, three triggers, one job on a fresh runner, two steps — one uses:, one run:.
# .github/workflows/ci.yml
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Run the build
run: node build.js
# 3. Commit the workflow file alongside your code.
git add .github/workflows/ci.yml
git commit -q -m "Add CI workflow"
git log --oneline
# Output (SHAs differ):
# a7f3b21 (HEAD -> main) Add CI workflow
# 4c1a9e2 Initial commit
# 4. Create the GitHub repo and push — gh uses your login, no URL to copy.
gh repo create gha-lab --public --source=. --push
# Output (your account name replaces "pushkar"):
# ✓ Created repository pushkar/gha-lab on github.com
# ✓ Added remote https://github.com/pushkar/gha-lab.git
# ✓ Pushed commits to https://github.com/pushkar/gha-lab.git
# 5. The push already fired the workflow (on: push). List the runs.
gh run list
# Output (run IDs, ages and SHAs are yours-will-differ):
# STATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE
# ✓ Add CI workflow CI main push 16983245712 22s 1m
# 6. Open the latest run and read the job result.
gh run view
# Output (IDs and timings differ):
# ✓ main CI · 16983245712
# Triggered via push about 1 minute ago
#
# JOBS
# ✓ build in 19s (ID 47120933851)
# 7. Read the full step log — checkout ran first, then your build command printed.
gh run view --log | grep -E "Check out|Run the build|build ok"
# Output (line prefixes carry job/step names and timestamps — yours will differ):
# build Check out the code ...
# build Run the build ...
# build Run the build build ok
# 8. Trigger it by hand — this is exactly what workflow_dispatch enables.
gh workflow run CI
# Output:
# ✓ Created workflow_dispatch event for ci.yml at main
# 9. A second run appears, this time with event = workflow_dispatch.
gh run list --limit 2
# Output (IDs/ages differ):
# STATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE
# ✓ CI CI main workflow_dispatch 16983261004 18s 12s
# ✓ Add… CI main push 16983245712 22s 2m
Read the last outputs back to yourself: you committed one YAML file, pushed it, and GitHub — with nobody watching — spun up a fresh Ubuntu runner, checked out your code, ran your command, and returned a green check. Then you fired the exact same job by hand with one command. That is the entire GitHub Actions loop; every later day just adds keys to this file.
Common Errors & Fixes
These three catch nearly everyone writing their first workflow. Read the error text slowly — learning to parse it is the actual skill.
Common error: A misindented
on:orsteps:block — YAML is whitespace-sensitive, and a workflow that doesn’t parse never runs. GitHub surfaces it on the Actions tab and ingh:Invalid workflow file: .github/workflows/ci.yml#L8 (Line: 8, Col: 5): Unexpected value 'steps'Why: YAML uses indentation, not braces, to nest keys. If
steps:sits one space off from where the parser expects it under the job, GitHub can’t tell whether it belongs to the job or the workflow, so it rejects the whole file rather than guess. Tabs instead of spaces trip the same error, because YAML forbids tabs for indentation entirely.Fix: Indent with spaces only (two per level is the convention), and keep every key in a block at the same column. Paste the file into a YAML linter or run
gh workflow view CI— a valid file shows its jobs; an invalid one repeats the parse error with the exact line and column.How you’d spot it in prod: A brand-new or just-edited workflow that simply never appears in the Actions tab, or shows a red “Invalid workflow file” annotation on the commit, is almost always an indentation slip — not a logic bug. Check the reported line and column before touching anything else.
Common error: Forgetting the
actions/checkoutstep, then having arun:command fail because the runner never got the code:Run node build.js Error: Cannot find module '/home/runner/work/gha-lab/gha-lab/build.js' Process completed with exit code 1Why: A hosted runner starts empty — it does not have your repository until a step clones it.
actions/checkoutis that step. Without it,node build.jsruns in a directory with nobuild.js, exits non-zero, and GitHub marks the step (and the run) failed.Process completed with exit code 1is the generic signal that arun:command returned a non-zero status.Fix: Add
- uses: actions/checkout@v4as the first step in the job, before any step that reads repo files. Re-push; the checkout now populates the working directory and the build finds its file.How you’d spot it in prod: A pipeline that fails immediately with “file not found” or “no such file or directory” on the very first real step — while the file plainly exists in the repo — is the classic missing-checkout signature. Confirm the checkout step is present and runs before anything that touches the code.
Common error: Running
gh workflow run(or clicking Run workflow) when the workflow lacks aworkflow_dispatchtrigger, or the version with it hasn’t reached the default branch yet:could not create workflow dispatch event: HTTP 422: Workflow does not have 'workflow_dispatch' triggerWhy: Manual runs require the
workflow_dispatchtrigger, and GitHub reads it from the workflow file on the default branch. If you added the trigger on a feature branch but never merged it tomain, the copy GitHub consults still has no manual trigger, so the dispatch is rejected with a 422.Fix: Add
workflow_dispatch:underon:, then commit and push it to the default branch. Once that version lands onmain, the Run workflow button appears andgh workflow run CIsucceeds.How you’d spot it in prod: A teammate says “the Run workflow button is missing” or a manual re-run 422s, even though the trigger is clearly in the file on their branch. The tell is that the change hasn’t merged to the default branch yet — that’s the only copy manual dispatch reads.
GitHub Actions Interview Questions
The workflow anatomy and trigger reasoning below are among the most common first-round CI/CD screening questions — a calm answer that explains why the runner starts empty beats reciting YAML keys. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Open the Actions tab of your
gha-labrepo in the browser, click the latest run, and expand each step to read the log GitHub streamed live — the same outputghprinted, one click away. - 10 min — Read GitHub’s official Workflow syntax reference for
on,jobs,runs-onandsteps; bookmark it — it’s the page you’ll reopen all phase. - 15 min — Add a second
run:step to yourci.ymlthat runsnode -v, commit and push, and watch a fresh run appear — then read the Understanding GitHub Actions overview and name every part you just used.
What is a GitHub Actions workflow, and where does it live? Both
A workflow is an automated process defined in a YAML file you commit to your repo under .github/workflows/. GitHub watches that folder and runs the file when a matching event fires. The anatomy is small: name labels it, on lists the triggering events, and jobs holds one or more jobs. Each job picks a machine with runs-on and runs an ordered list of steps. A step either uses a prebuilt action or runs a shell command. Because the file lives in the repo, the pipeline is versioned with the code — a pull request can change the build itself, and reviewers see it. That's the whole model: push code, a file describes what to do, GitHub does it.
What's the difference between the push and pull_request triggers? Both
Both fire on code changes, but at different moments. push fires when commits land on a branch — it's your after-the-fact safety net, building whatever was just pushed. pull_request fires when a PR is opened or updated, and it runs against the merge result — your branch combined with the target — before anything lands. That's what produces the green check reviewers gate merges on: it answers 'will main still build if we merge this?' In practice I use both. push gives every commit a status; pull_request protects the branch everyone shares. Listing several events under on: means any one of them starts the run.
What does runs-on: ubuntu-latest give you, and what is a runner? Both
runs-on tells GitHub which machine the job needs. runs-on: ubuntu-latest requests a GitHub-hosted runner — a fresh, throwaway Ubuntu VM that GitHub provisions for this run and destroys afterward, so every run starts clean with no leftover state. A runner is simply the machine that executes your steps; hosted runners come with common tools preinstalled, and you can register your own self-hosted runners for special hardware or private networks. That throwaway nature is why nearly every workflow's first step is actions/checkout — the runner starts empty, without even your code, so you must clone it in before any step can see it. A clean box every time is the point.
What's the difference between a step that uses an action and one that runs a command? Product
A step is one item in a job, and it's one of two kinds. uses: pulls in a published, versioned action — reusable code someone packaged, like actions/checkout@v4, which clones your repo onto the runner. run: executes a shell command directly on the machine, exactly like typing it in a terminal — run: npm test, say. My rule of thumb: reach for an existing action when one fits the task (checking out code, setting up a language, logging into a registry) and drop to run for your own project commands. Pinning the action version with @v4 matters — it stops a surprise update from silently breaking your build.
Mark Day 32 complete
Tomorrow you level up the same file — multiple jobs running in parallel, a build matrix across versions, and secrets injected safely at run time.
Stuck on today’s lab? Ask in Mission 90 Q&A