CI/CD with GitHub Actions
Set up a pipeline that tests your code on every push and builds a Docker image when tests pass. You will write a real GitHub Actions workflow, read its logs and fix a failing run.
What you'll build: a GitHub Actions workflow for a small Python API (the Brew & Bean menu service). On every push and pull request it installs dependencies and runs pytest, and on the main branch only it builds a Docker image. Prerequisites: a free GitHub account, Git basics (see *Git and GitHub: the team workflow*) and the Dockerfile idea from *Docker: images and containers*. Python testing itself is taught in the Testing track.
What CI and CD mean
Continuous Integration (CI) means every change is automatically built and tested as soon as it is pushed. Nobody has to remember to run the tests, and a broken change is caught within minutes, while the author still remembers what they did.
Continuous Delivery or Deployment (CD) means a change that passes the checks is automatically prepared for release (delivery) or released to users (deployment). Many teams keep a human approval step before production; that is still CD.
Think of a pipeline as a factory line with quality gates. If a gate fails, the product stops there.
GitHub Actions vocabulary
The words you need
| Term | Meaning | In our example |
|---|---|---|
| Workflow | A YAML file in .github/workflows/ that describes an automated process | ci.yml |
Event (on) | What starts the workflow | push, pull_request |
| Job | A group of steps that runs on one fresh machine | test, docker |
| Runner | The machine that runs a job | ubuntu-latest (hosted by GitHub) |
| Step | One command (run) or one reusable action (uses) | pytest |
Jobs run in parallel by default. Each job starts on a clean runner, so nothing is shared between jobs unless you pass it explicitly. This is why you check out the code and install dependencies in every job that needs them.
Step 1: a tiny project to test
Create a folder menu-service and turn it into a Git repository. We need an app, a test, a requirements file and a Dockerfile.
fastapi==0.115.0
httpx==0.27.2
pytest==8.3.3
uvicorn==0.30.6Pinned versions keep CI runs repeatable. Newer versions will also work; update the pins when you wish. httpx is needed by FastAPI's test client.
from fastapi import FastAPI
app = FastAPI()
MENU = [
{"id": "espresso", "name": "Espresso", "price": 120},
{"id": "masala-chai", "name": "Masala Chai", "price": 90},
]
@app.get("/menu")
def get_menu():
return MENU
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_menu_returns_items():
response = client.get("/menu")
assert response.status_code == 200
assert len(response.json()) == 2
def test_prices_are_positive():
for item in client.get("/menu").json():
assert item["price"] > 0
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Run the tests on your own machine first. If they fail locally they will fail in CI, and fixing them here is faster.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
pytest -q.. [100%]
2 passed in 0.5sStep 2: write the workflow
Create the file .github/workflows/ci.yml (the folder names matter exactly). Read the explanation after the code: each line has a reason.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest -q
docker:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t menu-service:${{ github.sha }} .
on runs the workflow for pushes to main and for every pull request. permissions gives the jobs read-only access to the repo (least privilege). needs: test makes the docker job wait for tests, and if limits it to main.
Key lines explained:
actions/checkout@v4 copies your repository onto the runner (it starts empty). actions/setup-python@v5 installs Python, and cache: pip reuses downloaded packages between runs to save time. ${{ github.sha }} is an expression that GitHub replaces with the commit id, so every image gets a unique tag.
YAML cares about indentation. Use spaces only, never tabs, and keep the steps aligned.
Step 3: push and watch it run
Push your first run
- 1Create an empty repository on GitHub (no README) and copy its URL.
- 2In your folder run
git init, then add a.gitignorecontaining.venv/and__pycache__/. - 3Run
git add .andgit commit -m "Add menu service and CI". - 4Run
git branch -M main, thengit remote add origin <your-repo-url>andgit push -u origin main. - 5Open the repository on GitHub and click the Actions tab. You will see the run named CI.
- 6Click the run, then a job, then a step to read its log. A green tick means every step passed.
CI (Add menu service and CI)
test passed about 30 to 60 seconds
docker passed waits for test, then builds the image
The exact timings vary.Step 4: see a failure and fix it
A pipeline is only useful if it fails when the code is wrong. Create a branch, break something on purpose and open a pull request.
git switch -c break-price
# edit main.py: change the Espresso price from 120 to -5
git commit -am "Change price"
git push -u origin break-priceOpen a pull request on GitHub. The Checks section shows the CI run in red. Open the log of the Run tests step: you will find the assertion item["price"] > 0 failing, with the line of code. Fix the price, commit, push, and the run turns green on its own. The docker job did not run on this branch because of the if condition.
Protect main with required checks
In the repository go to Settings, then Branches (or Rules), and add a rule for main that requires the CI check to pass before merging. Now broken code cannot reach main. Available options depend on your plan and repository type; check the GitHub docs if a setting is missing.
Secrets and safety
Pipelines often need passwords or tokens, for example to push an image to a registry. Never write them in the YAML file or commit them. Store them under Settings, Secrets and variables, Actions, and read them as ${{ secrets.NAME }}. GitHub hides secret values in logs.
Other good habits: keep permissions as small as possible, pin actions to a major version such as @v4, and be careful with workflows triggered by pull requests from forks, which do not receive your secrets by default.
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}Pushing an image needs a registry account and the two secrets created first. We stop at building here; deploying to a cloud service is covered later in the track.
A leaked secret is a compromised secret
If you ever commit a token, deleting the file is not enough, because it stays in Git history. Revoke the token at the provider immediately and create a new one.
Making pipelines faster and tidier
Common improvements
| Goal | How |
|---|---|
| Faster installs | cache: pip in setup-python (or cache: npm for Node) |
| Test several versions | strategy: with matrix: and python-version: ["3.11", "3.12"] on the job |
| Stop duplicate runs | a concurrency: group with cancel-in-progress: true |
| Run only when needed | a paths: filter under on: push |
| Keep files out of Git | .gitignore, and never commit .env |
My GitHub Actions job failed. Explain in simple terms what went wrong and give the smallest fix.
Workflow file:
```
[paste ci.yml]
```
Failing log lines:
```
[paste the last 30 lines of the failed step]
```What to expect: Check that the answer points to a specific line, and run the fix locally if you can before pushing.
How this comes up in interviews
Expect: the difference between CI, continuous delivery and continuous deployment; what a runner is; how you keep secrets out of code; how you would speed up a slow pipeline; and what you do when a build is red on main. A strong answer to the last one is: stop merging, find the commit, and either revert it or fix forward quickly, then ask why the check did not catch it earlier.
Try it: Extend the pipeline
Add a lint step before the tests using pip install ruff and ruff check .. Then add a matrix so tests run on Python 3.11 and 3.12. Push on a branch and confirm both matrix jobs appear.
Self-check
- My tests pass on my laptop before I push
- The workflow file is under
.github/workflows/and ends in.yml - I can open a run and find the failing step
- No secret appears in any file in the repository
- The image is built only on main
Key takeaways
- CI runs your tests automatically on every change; CD prepares or ships the result.
- A workflow has events, jobs, runners and steps; each job starts on a clean machine.
needsorders jobs andiflimits where they run.- Store tokens in repository secrets, never in YAML or Git.
- Make the CI check required on
mainso broken code cannot be merged.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why does every job in a workflow usually begin with actions/checkout?
2. What does needs: test do on the docker job?
3. Where should a registry password be stored?
4. A pull request shows a red CI check. What is the best first action?
5. What is the difference between continuous delivery and continuous deployment?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.