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.yml for testing, deploy.yml for deployment, nightly.yml for 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 write github/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:

ActionWhat it does
actions/checkout@v4Downloads the repo's code onto the runner
actions/setup-python@v5Installs a specific version of Python
actions/cache@v4Caches dependencies between runs
actions/upload-artifact@v4Saves 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

RunnerOSTypical use
ubuntu-latestUbuntu 22.04The most common, recommended for CI
ubuntu-24.04Ubuntu 24.04Specific version
macos-latestmacOS 14When you need macOS
windows-latestWindows Server 2022When 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/checkout as the first step). Your dependencies aren't installed (that's why you need pip 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

FeatureGitHub ActionsJenkinsGitLab CICircleCI
Setup0 (it's already in GitHub)Your own serverComes with GitLabSeparate account
ConfigYAML in the repoUI + GroovyYAML in the repoYAML in the repo
HostingGitHub-hostedSelf-hostedGitLab-hostedCloud
Free tier2,000 min/monthFree (self-hosted)400 min/month6,000 min/month
EcosystemEnormous marketplacePluginsTemplatesOrbs
Learning curveLowHighMediumMedium

Why GitHub Actions for this guide?

  1. You're already on GitHub. You don't need to create accounts on another platform.
  2. Zero setup. There's no server to maintain.
  3. Generous free tier. 2,000 min/month for private repos, unlimited for public ones.
  4. Marketplace. Thousands of reusable actions for Docker, deployments, notifications.
  5. One tool, learned well. Better than knowing three superficially.

Troubleshooting

"What if my workflow doesn't run?"

The most common causes:

  1. The file isn't in .github/workflows/. Check the exact path (with the dot).
  2. The trigger doesn't match. If you have on: push but you push to a branch that isn't in the filter.
  3. YAML syntax error. GitHub silently ignores workflows with invalid YAML. Check the Actions tab to see whether an error shows up.
  4. 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 CI file)
  • Jobs: 2 (lint and test)
  • 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) or uses (reusable action)
  • Runner = clean VM where a job runs; ubuntu-latest is the recommended default
  • Jobs run in parallel by default; use needs to make them sequential
  • Every runner starts clean — you need checkout and install in every job
  • actions/checkout@v4 is the first step of almost every job (it brings your code to the runner)
  • Naming steps significantly improves the debugging experience

Additional resources

  1. Understanding GitHub Actions - Official concepts
  2. Workflow Syntax Reference - Complete YAML syntax reference
  3. GitHub-hosted Runners - Specs of available runners
  4. GitHub Actions Marketplace - Reusable actions
  5. Events that trigger workflows - All available triggers
  6. act - Run GitHub Actions locally - Tool for local testing of workflows