Module 1: Introduction to CI/CD and GitHub Actions
3. GitHub Actions — Architecture and Concepts
Overview
Before writing your first workflow, you need to understand how GitHub Actions works under the hood. This capsule gives you the mental model: what a workflow is, what a job is, what a step is, what a runner is, and how they relate to each other. Without this model, you'll write YAML by copying and pasting without understanding why something works or why it fails.
Think of GitHub Actions as an automation system that lives inside your GitHub repository. Every time something happens in your repo (a push, a PR, a schedule), GitHub Actions can run code for you on a clean virtual machine. You define what to run and when, using YAML files.
The hierarchy: Workflow → Job → Step
The relationship between these three concepts is the basis of everything. If you understand it, you understand GitHub Actions:
Repository
└── .github/workflows/
└── ci.yml ← WORKFLOW (YAML file)
├── Job: "lint" ← JOB (execution unit)
│ ├── Step 1: Checkout ← STEP (individual action)
│ ├── Step 2: Setup Python
│ └── Step 3: Run ruff
│
├── Job: "test" ← Another JOB (can run in parallel)
│ ├── Step 1: Checkout
│ ├── Step 2: Setup Python
│ ├── Step 3: Install deps
│ └── Step 4: Run pytest
│
└── Job: "build" ← Depends on the previous ones
├── Step 1: Checkout
├── Step 2: Docker build
└── Step 3: Docker push
Analogy: The factory
- Workflow = The complete production plant (the whole process)
- Job = A workstation (lint, test, build are different stations)
- Step = A specific task at the station (install tools, run a command)
- Runner = The operator who carries out the tasks (GitHub's virtual machine)
Each station (job) has its own operator (runner) — they work on separate machines. They can work in parallel or in sequence.
Workflows
A workflow is a YAML file that defines a complete automated process. It lives in .github/workflows/ inside your repository.
Key characteristics
- A repo can have multiple workflows. Example:
ci.ymlfor testing,deploy.ymlfor deployment,nightly.ymlfor nightly checks. - Each workflow is activated by triggers. A push, a PR, a schedule, a manual trigger.
- A workflow contains one or more jobs.
Minimal structure
# .github/workflows/ci.yml
name: CI Pipeline # Name visible in GitHub's UI
on: push # Trigger: when it runs
jobs: # The jobs it contains
test: # Name of the job
runs-on: ubuntu-latest # Which machine it runs on
steps: # The job's steps
- run: echo "Hello CI" # A simple step
Where it lives
my-project/
├── .github/
│ └── workflows/
│ ├── ci.yml # CI workflow
│ ├── deploy.yml # Deploy workflow
│ └── nightly.yml # Scheduled workflow
├── src/
└── tests/
Important: The directory MUST be
.github/workflows/(with the dot). If you writegithub/workflows/(without the dot), GitHub won't detect it.
Jobs
A job is an execution unit within a workflow. Each job runs on a separate runner (an independent virtual machine).
Key characteristics
- Jobs run in parallel by default. If you have 3 jobs and don't define dependencies, all 3 start at the same time.
- You can make jobs sequential with
needs. - Each job has its own runner. The files from one job are NOT available in another job (unless you use artifacts).
- If a job fails, the jobs that depend on it are cancelled.
Jobs in parallel (default)
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install ruff && ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pytest && pytest tests/
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install mypy && mypy src/
Execution: lint ──────────────►
test ──────────────► (all 3 in parallel)
typecheck ─────────►
Sequential jobs (with needs)
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/
build:
needs: test # Only runs if "test" passed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t myapp .
deploy:
needs: build # Only runs if "build" passed
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
Execution: test ───► build ───► deploy
(sequential: each one waits for the previous)
Mixed jobs (parallel + sequential)
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "linting..."
test:
runs-on: ubuntu-latest
steps:
- run: echo "testing..."
build:
needs: [lint, test] # Waits for BOTH
runs-on: ubuntu-latest
steps:
- run: echo "building..."
Execution: lint ────────────┐
test ────────────┤───► build
│
(lint and test in parallel, build waits for both)
This is the most common pattern in CI pipelines: validations in parallel, build after they all pass.
Steps
A step is an individual action within a job. It can be a shell command or a reusable action.
Two types of steps
1. run — Run a shell command
steps:
- run: echo "Hello World"
- run: pip install -r requirements.txt
- run: pytest tests/ -v
For multi-line commands, use the pipe (|):
steps:
- run: |
echo "Installing dependencies..."
pip install -r requirements.txt
echo "Running tests..."
pytest tests/ -v --tb=short
2. uses — Use an existing action
steps:
- uses: actions/checkout@v4 # Downloads your code
- uses: actions/setup-python@v5 # Sets up Python
with:
python-version: "3.12"
Actions are reusable packages that the community (and GitHub) publish. The most common ones:
| Action | What it does |
|---|---|
actions/checkout@v4 | Downloads the repo's code onto the runner |
actions/setup-python@v5 | Installs a specific version of Python |
actions/cache@v4 | Caches dependencies between runs |
actions/upload-artifact@v4 | Saves files to download later |
Naming steps (good practice)
steps:
- name: Checkout code # Descriptive name
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v
The names show up in GitHub Actions' UI. Without names, you only see "Run actions/checkout@v4" — with names you see "Checkout code", "Setup Python 3.12", etc. Much easier to debug.
Runners
A runner is the virtual machine where a job runs. GitHub provides free runners (hosted runners).
Available runners
| Runner | OS | Typical use |
|---|---|---|
ubuntu-latest | Ubuntu 22.04 | The most common, recommended for CI |
ubuntu-24.04 | Ubuntu 24.04 | Specific version |
macos-latest | macOS 14 | When you need macOS |
windows-latest | Windows Server 2022 | When you need Windows |
For this guide: ubuntu-latest
jobs:
test:
runs-on: ubuntu-latest # Recommended for most cases
steps:
- run: echo "Running on Ubuntu"
Why ubuntu? It's the fastest runner, it has all the tools you need (Python, Docker, git), and it consumes fewer minutes of your quota.
What a runner has
Each runner is a clean VM that includes:
- ✅ Git, Docker, Python, Node.js (multiple versions)
- ✅ pip, npm, cargo, go
- ✅ Build tools (gcc, make)
- ✅ CLI tools (curl, wget, jq)
- ✅ ~14 GB of RAM, 2 CPUs
Key insight: Every job starts with a clean runner. There's nothing from previous runs. Your code isn't there (that's why you need
actions/checkoutas the first step). Your dependencies aren't installed (that's why you needpip install). This cleanliness guarantees reproducibility.
The complete flow: From push to result
Let's look at the full life cycle when you do git push:
1. git push origin main
│
▼
2. GitHub receives the push
│
▼
3. GitHub looks for files in .github/workflows/
│
▼
4. For each workflow whose trigger matches (e.g. on: push):
│
▼
5. GitHub creates a "workflow run" (visible in the Actions tab)
│
▼
6. For each job in the workflow:
a. GitHub assigns a runner (clean VM)
b. The runner executes each step in order
c. If a step fails, the job fails
d. The runner is destroyed when it finishes
│
▼
7. Result visible in GitHub's UI:
✅ green (passed) or ❌ red (failed)
Visual timeline
t=0s Push arrives at GitHub
t=2s GitHub detects the workflow
t=5s Runner assigned (VM being created)
t=15s Checkout completed
t=20s Python configured
t=45s Dependencies installed (pip install)
t=50s Tests running
t=55s Tests completed ✅
t=56s Runner destroyed
The times are approximate. With caching (Module 2), pip install can drop from 25 seconds to 2 seconds.
Comparison: GitHub Actions vs other platforms
| Feature | GitHub Actions | Jenkins | GitLab CI | CircleCI |
|---|---|---|---|---|
| Setup | 0 (it's already in GitHub) | Your own server | Comes with GitLab | Separate account |
| Config | YAML in the repo | UI + Groovy | YAML in the repo | YAML in the repo |
| Hosting | GitHub-hosted | Self-hosted | GitLab-hosted | Cloud |
| Free tier | 2,000 min/month | Free (self-hosted) | 400 min/month | 6,000 min/month |
| Ecosystem | Enormous marketplace | Plugins | Templates | Orbs |
| Learning curve | Low | High | Medium | Medium |
Why GitHub Actions for this guide?
- You're already on GitHub. You don't need to create accounts on another platform.
- Zero setup. There's no server to maintain.
- Generous free tier. 2,000 min/month for private repos, unlimited for public ones.
- Marketplace. Thousands of reusable actions for Docker, deployments, notifications.
- One tool, learned well. Better than knowing three superficially.
Troubleshooting
"What if my workflow doesn't run?"
The most common causes:
- The file isn't in
.github/workflows/. Check the exact path (with the dot). - The trigger doesn't match. If you have
on: pushbut you push to a branch that isn't in the filter. - YAML syntax error. GitHub silently ignores workflows with invalid YAML. Check the Actions tab to see whether an error shows up.
- Actions disabled. In forked repos, Actions may be disabled by default. Go to Settings → Actions → General.
"Can I run the workflow on my machine?"
Not directly. GitHub Actions runs on GitHub's runners. But you can:
- Use act to run workflows locally (useful for debugging)
- Test the individual commands (lint, test, build) in your terminal before putting them in the workflow
"How many free minutes do I have?"
- Public repos: Unlimited minutes
- Private repos: 2,000 min/month on the free tier
- Note: macOS and Windows minutes are multiplied (macOS = 10x, Windows = 2x). Ubuntu isn't multiplied.
For this guide, with ubuntu-latest and simple workflows, you'll never get close to the limit.
Exercises
Exercise 1: Identify the components
Given this workflow, identify: how many workflows, jobs, steps, and runners are there?
name: CI
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/ -v
See solution
- Workflows: 1 (the
CIfile) - Jobs: 2 (
lintandtest) - Steps in lint: 3 (checkout, install ruff, run ruff)
- Steps in test: 4 (checkout, setup python, install deps, pytest)
- Runners: 2 (one for each job, both
ubuntu-latest)
Note: lint and test run in parallel because there's no needs between them. Each one has its own independent runner.
Exercise 2: Parallel vs sequential
Modify this workflow so that build only runs if lint AND test pass:
name: CI
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "linting..."
test:
runs-on: ubuntu-latest
steps:
- run: echo "testing..."
build:
runs-on: ubuntu-latest
steps:
- run: echo "building..."
See solution
name: CI
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "linting..."
test:
runs-on: ubuntu-latest
steps:
- run: echo "testing..."
build:
needs: [lint, test] # Waits for both
runs-on: ubuntu-latest
steps:
- run: echo "building..."
Resulting execution:
lint ────────────┐
test ────────────┤───► build
lint and test run in parallel. build waits for both to finish successfully. If either fails, build is cancelled.
Exercise 3: What's missing?
This workflow has a conceptual error. What is it?
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: pip install -r requirements.txt
- run: pytest tests/ -v
See solution
actions/checkout@v4 is missing. The runner starts with a clean VM — your code isn't there. Without checkout, requirements.txt and tests/ don't exist on the runner, and both commands will fail with "file not found."
Corrected version:
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Downloads your code
- run: pip install -r requirements.txt
- run: pytest tests/ -v
This is probably the most common error in new workflows.
Exercise 4: Clean runner
Why does each job need its own actions/checkout@v4?
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Why repeat this?
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # And here too?
- run: pytest tests/
See solution
Because each job runs on a separate runner — an independent, clean VM. The checkout that the lint job did doesn't exist on the test job's runner. They're different machines.
Consequences:
- Each job needs its own checkout
- Each job needs to install its own dependencies
- Files created in one job are NOT available in another (use artifacts to share)
Advantage: Complete isolation. If lint corrupts something, test isn't affected.
Summary
- ✅ Workflow = YAML file in
.github/workflows/that defines the whole process - ✅ Job = execution unit within the workflow; each job has its own runner
- ✅ Step = individual action; can be
run(command) oruses(reusable action) - ✅ Runner = clean VM where a job runs;
ubuntu-latestis the recommended default - ✅ Jobs run in parallel by default; use
needsto make them sequential - ✅ Every runner starts clean — you need checkout and install in every job
- ✅
actions/checkout@v4is the first step of almost every job (it brings your code to the runner) - ✅ Naming steps significantly improves the debugging experience
Additional resources
- Understanding GitHub Actions - Official concepts
- Workflow Syntax Reference - Complete YAML syntax reference
- GitHub-hosted Runners - Specs of available runners
- GitHub Actions Marketplace - Reusable actions
- Events that trigger workflows - All available triggers
- act - Run GitHub Actions locally - Tool for local testing of workflows