CI Pipelines in Practice with GitHub Actions
Learn to read, write, and debug GitHub Actions workflows — the industry-standard CI system built into GitHub.
What GitHub Actions is
GitHub Actions is a CI/CD system that runs inside GitHub. When events happen in your repository — a push, a pull request, a scheduled timer — GitHub Actions triggers automated workflows defined in YAML files you commit alongside your code.
The key distinction from separate CI services like Jenkins or CircleCI is that GitHub Actions is integrated: the configuration lives in the repository, the workflows trigger on repository events, and the results appear directly on pull requests. No separate service to sign up for, no webhooks to configure.
GitHub provides hosted runners — virtual machines that execute your workflows. You get 2,000 free minutes per month on the free plan; most student projects use a small fraction of this.
Anatomy of a workflow file
Workflow files live in .github/workflows/ and must have a .yml or .yaml extension. Here is a complete, annotated example:
# Human-readable name shown in the Actions tab
name: CI
# When to run this workflow
on:
push:
# Only trigger on these branches
branches: [main, develop]
pull_request:
# Trigger on PRs targeting main
branches: [main]
# A workflow contains one or more jobs
jobs:
# Job name — choose something descriptive
test:
# The OS and version for the runner VM
runs-on: ubuntu-latest
# Each job has a sequence of steps
steps:
# Check out the repository code onto the runner
- name: Checkout code
uses: actions/checkout@v4
# Set up the Python version
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Install dependencies
- name: Install dependencies
run: pip install -r requirements.txt
# Run the test suite
- name: Run tests
run: pytest -v
Triggers in depth
The on: key controls when the workflow runs. Understanding triggers lets you build efficient pipelines that run only when needed.
# Push to any branch
on:
push
# Push or PR on specific branches
on:
push:
branches: [main, 'feature/**']
pull_request:
branches: [main]
# Scheduled (cron syntax)
on:
schedule:
# Run at 09:00 UTC every weekday
- cron: '0 9 * * 1-5'
# Manual trigger (adds 'Run workflow' button)
on:
workflow_dispatch
# On new GitHub release
on:
release:
types: [published]
Jobs and runners
A workflow can contain multiple jobs. By default, jobs run in parallel. You can declare dependencies to run them sequentially:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install flake8 && flake8 .
test:
# This job waits for 'lint' to succeed before starting
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest
GitHub provides runners for ubuntu-latest, windows-latest, and macos-latest. Ubuntu is most commonly used for server-side code.
Steps: run vs uses
Every step does one of two things:
run:uses:# run: executes bash commands
- name: Build application
run: |
echo 'Starting build'
python3 -m build
echo 'Build complete'
# uses: runs a published Action
- name: Upload test report
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.xml
Always specify a version tag for actions: actions/checkout@v4 not actions/checkout. Without a tag, the action uses the latest version, which may break when the action is updated. The @v4 tag is stable — it only receives non-breaking updates.
Secrets and environment variables
Your application often needs configuration — API keys, database URLs, feature flags. These should never be hard-coded in the workflow file (which is in version control). GitHub provides two mechanisms:
Environment variables
jobs:
test:
# Job-level env vars
env:
APP_ENV: test
LOG_LEVEL: debug
steps:
- name: Run tests
# Step-level env var (overrides job-level)
env:
DATABASE_URL: sqlite:///:memory:
run: pytest
Secrets
Secrets are encrypted values stored in GitHub (Settings → Secrets → Actions). They are never written to logs.
- name: Deploy
env:
# Secrets are referenced with ${{ secrets.NAME }}
DEPLOY_API_KEY: ${{ secrets.DEPLOY_API_KEY }}
run: ./deploy.sh
Caching dependencies
Installing Python packages on every pipeline run takes 20–60 seconds. Caching stores the installed packages and reuses them if requirements.txt has not changed.
- name: Cache pip packages
uses: actions/cache@v4
with:
# Cache key: unique per OS and requirements file hash
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
path: ~/.cache/pip
- name: Install dependencies
run: pip install -r requirements.txt
If requirements.txt has not changed, the cache hit saves the install step entirely. If it has changed, the new packages are installed and the cache is updated.
Debugging a failing pipeline
Failing pipelines are normal. The process for fixing them is systematic:
- Click the failed run in the Actions tab. Look for the red ✗ icon.
- Identify the failing step. Each step has its own expandable log.
- Read from the first error. Error messages propagate — the first error is the root cause.
- Look for file and line numbers. Python errors always include these.
- Reproduce locally. Run the exact command from the failing step.
- Fix and push. The pipeline re-runs automatically.
# Print debug information when a step fails
- name: Debug environment
# if: failure() means this only runs if a previous step failed
if: failure()
run: |
python3 --version
pip list
echo "PATH: $PATH"
ls -la
Worked pipeline walkthrough
Let us trace the complete execution of the annotated workflow above:
- Trigger: A developer pushes to the
mainbranch. GitHub detects the push and starts the workflow. - Runner allocation: GitHub provisions a fresh Ubuntu VM. Every run starts with a clean environment.
- Checkout:
actions/checkout@v4clones the repository onto the runner at the pushed commit. - Python setup:
actions/setup-python@v5installs Python 3.11 and adds it to PATH. - Install:
pip install -r requirements.txtinstalls all declared dependencies. - Tests:
pytest -vruns all tests. Each test is reported. If any fail, the step exits with code 1. - Result: If all steps exited with code 0, the workflow shows green. GitHub marks the commit with a tick. If any step failed, the workflow shows red and the commit gets a cross. Any open PR targeting this branch cannot be merged until the checks pass.
Key terms
Exercises
Part A: First pipeline
- Fork the provided Python starter repository.
- Create the directory .github/workflows/ and add a file called ci.yml.
- Write a workflow that: triggers on push and pull_request, uses ubuntu-latest, sets up Python 3.11, installs requirements.txt, runs pytest.
- Push the file and check the Actions tab. Did it pass?
- Deliberately break a test. Push and observe the failure in the pipeline.
- Fix the test and push again.
Part B: Adding a lint step
- Add
flake8to your requirements.txt and to the workflow as a step before tests. - Introduce a style error (
import osat the top but never use it). Push and observe flake8 catching it. - Fix the error and confirm the pipeline passes.
Part C: Environment variables
- Add an environment variable APP_ENV=test to the job level of the workflow.
- Modify a test to check that the APP_ENV variable is set (use os.getenv('APP_ENV')).
- Push and confirm the test passes in CI.
Part D: Caching
- Add the pip caching step to your workflow (before the install step).
- Push twice. Check the second run's log — does the cache step show a hit?
- Measure the time difference between a run with and without caching.
Extension: Status badge
Add a GitHub Actions status badge to your README.md. The badge URL is: https://github.com/YOUR-USERNAME/YOUR-REPO/actions/workflows/ci.yml/badge.svg.