A 60-minute DevOps lab. You will write a small Python service, test it, add a CI pipeline, push it to GitHub, and trigger a build.
Format: instructor walkthrough, then individual labs at your own pace
Python 3.12, git, a terminal, and curl. The GitHub CLI (gh) is optional but useful.
A GitHub account with permission to create a private repository.
Basic Python and basic command line. No prior CI/CD experience required.
Check quickly: python --version, git --version, gh --version.
| Time | Block | Output |
|---|---|---|
| 0-5 min | Orientation and tooling check | Tools verified |
| 5-15 min | Lab 1: Create the project | Folder with app, tests, requirements |
| 15-25 min | Lab 2: Test and run locally | Tests pass, server responds |
| 25-35 min | Lab 3: Add the pipeline and push | Repository on GitHub |
| 35-47 min | Lab 4: Watch a build, then break it | One green and one red run |
| 47-55 min | Lab 5: Fix, re-trigger, verify | Green run started by hand |
| 55-60 min | Review and questions | Notes on what to try next |
Goal: a project folder with the app, tests, requirements, and an empty workflow folder.
mkdir py-hello && cd py-hello mkdir -p .github/workflows touch app.py test_app.py requirements.txt .github/workflows/ci.yml
app.py and type the code below yourself. The point is to read each line.
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
def greet(name: str = "world") -> str:
return f"Hello, {name}!"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
body = {"status": "ok"}
else:
body = {"message": greet()}
payload = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8000"))
print(f"Listening on port {port}")
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
requirements.txt: pytestpython app.py # Expected: Listening on port 8000
python app.py runs without errors.If python is not found: try python3, or check the install with python --version.
Goal: tests pass and the server responds before anything goes to GitHub.
test_app.py:
from app import greet
def test_greet_default():
assert greet() == "Hello, world!"
def test_greet_with_name():
assert greet("Bastian") == "Hello, Bastian!"
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt
Windows PowerShell: .venv\Scripts\Activate.ps1
2 passed:
pytest -v
python app.py &
curl localhost:8000
curl localhost:8000/health
# {"message": "Hello, world!"}
# {"status": "ok"}
kill %1 printf ".venv/\n__pycache__/\n.pytest_cache/\n" > .gitignore
ModuleNotFoundError: app means pytest ran from a subfolder. Run it from the project root.
Address already in use means port 8000 is taken. Stop the other process, or run PORT=8001 python app.py and curl port 8001.
Goal: the code is in a private GitHub repository and the first pipeline run has started.
.github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest -v
- name: Smoke test the server
run: |
python app.py &
sleep 2
curl -f http://localhost:8000/health
on sets when the workflow runs. Each item under steps runs in order on a fresh Ubuntu machine. The smoke test starts the app and checks that /health responds.
git init -b main git add . git commit -m "Add app, tests, and CI"
# Option A: GitHub CLI gh auth login gh repo create py-hello --private --source=. --push # Option B: web UI # Create an empty private repo named py-hello on github.com, then: git remote add origin https://github.com/<your-user>/py-hello.git git push -u origin main
Workflow does not appear: confirm the file is at .github/workflows/ci.yml, the YAML indentation is correct, and you pushed to main.
2 passed.git checkout -b broken-test
test_app.py, add an extra ! to the expected value:
assert greet("Bastian") == "Hello, Bastian!!"
git add test_app.py git commit -m "Break greeting test on purpose" git push -u origin broken-test
main:
gh pr create --base main --head broken-test --fill
Or click "Compare & pull request" on GitHub after the push.
test_app.py:
assert greet("Bastian") == "Hello, Bastian!"
git add test_app.py git commit -m "Fix greeting test expectation" git push
gh pr merge --squash --delete-branch. The merge pushes to main and starts another run.git checkout main git pull
gh workflow run ci.yml gh run watch
gh run watch follows the newest run and exits with an error if it fails. In the browser: Actions → CI → Run workflow.
gh run list.gh workflow run says the workflow was not found: the file must exist on the default branch. Confirm that main contains ci.yml.
| Symptom | Likely cause | Where to look |
|---|---|---|
| No such file: requirements.txt | File not committed or wrong folder | git status, git ls-files |
| Smoke test: connection refused | Server not ready after sleep | Increase sleep, or poll /health in a loop |
| Address already in use | Port 8000 taken locally or on the runner | Set PORT, or stop the old process |
| ModuleNotFoundError: app | pytest run from a different directory | Run pytest from the repo root |
| Workflow not appearing | File not in .github/workflows, or invalid YAML | Check the path and indentation |
Add GET /version that returns a version string from an environment variable, with a test.
Add cache: pip to the setup-python step and compare run times.
Build the image in a second job and run the smoke test against the container.
Run pytest --cov and fail the build below a chosen percentage.
Questions and discussion