Hands-On Lab: From Code to Build

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

What you will be able to do

Before we start

Tools

Python 3.12, git, a terminal, and curl. The GitHub CLI (gh) is optional but useful.

Accounts

A GitHub account with permission to create a private repository.

Knowledge

Basic Python and basic command line. No prior CI/CD experience required.

Check quickly: python --version, git --version, gh --version.

Agenda

TimeBlockOutput
0-5 minOrientation and tooling checkTools verified
5-15 minLab 1: Create the projectFolder with app, tests, requirements
15-25 minLab 2: Test and run locallyTests pass, server responds
25-35 minLab 3: Add the pipeline and pushRepository on GitHub
35-47 minLab 4: Watch a build, then break itOne green and one red run
47-55 minLab 5: Fix, re-trigger, verifyGreen run started by hand
55-60 minReview and questionsNotes on what to try next
LAB 1 · 5 to 15 minutes

Create the project

Goal: a project folder with the app, tests, requirements, and an empty workflow folder.

  1. Create the folder structure:
    mkdir py-hello && cd py-hello
    mkdir -p .github/workflows
    touch app.py test_app.py requirements.txt .github/workflows/ci.yml
  2. Open 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()
  3. Put one line in requirements.txt: pytest
  4. Check that the server starts, then stop it with Ctrl+C:
    python app.py
    # Expected: Listening on port 8000
Checkpoint: the folder matches the layout and python app.py runs without errors.

If python is not found: try python3, or check the install with python --version.

LAB 2 · 15 to 25 minutes

Test and run locally

Goal: tests pass and the server responds before anything goes to GitHub.

  1. Write 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!"
  2. Create a virtual environment, activate it, and install dependencies:
    python -m venv .venv
    source .venv/bin/activate
    pip install -r requirements.txt

    Windows PowerShell: .venv\Scripts\Activate.ps1

  3. Run the tests. Expect 2 passed:
    pytest -v
  4. Start the server in the background and query it:
    python app.py &
    curl localhost:8000
    curl localhost:8000/health
    # {"message": "Hello, world!"}
    # {"status": "ok"}
  5. Stop the background server, then ignore generated files:
    kill %1
    printf ".venv/\n__pycache__/\n.pytest_cache/\n" > .gitignore
Checkpoint: pytest reports 2 passed, and curl returns both JSON responses.

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.

LAB 3 · 25 to 35 minutes

Add the pipeline and push

Goal: the code is in a private GitHub repository and the first pipeline run has started.

  1. Write .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.

  2. Make the first commit:
    git init -b main
    git add .
    git commit -m "Add app, tests, and CI"
  3. Create the repository and push. Choose one option.
    # 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
  4. Open the Actions tab in the repository and open the run for your commit.
Checkpoint: the Actions tab shows a run for your first commit. It may still be in progress.

Workflow does not appear: confirm the file is at .github/workflows/ci.yml, the YAML indentation is correct, and you pushed to main.

LAB 4 · 35 to 47 minutes

Watch a build, then break it

Part A: Read the green run
  1. In Actions, open your first run and expand each step.
  2. Find the "Run tests" step and the line that reports 2 passed.
  3. Compare "Install dependencies" with "Run tests". Installing is usually the slower step on a fresh runner.
Part B: Break it on purpose
  1. Create a branch:
    git checkout -b broken-test
  2. In test_app.py, add an extra ! to the expected value:
    assert greet("Bastian") == "Hello, Bastian!!"
  3. Commit and push the branch:
    git add test_app.py
    git commit -m "Break greeting test on purpose"
    git push -u origin broken-test
  4. Open a pull request into main:
    gh pr create --base main --head broken-test --fill

    Or click "Compare & pull request" on GitHub after the push.

  5. On the pull request, click Details on the failing check, expand "Run tests", and note the assertion error.
Checkpoint: one green run on main, one red run on the pull request, and you can name the failing step and assertion.
LAB 5 · 47 to 55 minutes

Fix, re-trigger, and verify

Part A: Fix through the pull request
  1. Restore the correct assertion in test_app.py:
    assert greet("Bastian") == "Hello, Bastian!"
  2. Commit and push to the same branch:
    git add test_app.py
    git commit -m "Fix greeting test expectation"
    git push
  3. Wait for the pull request check to turn green.
  4. Merge with Merge pull request, or run gh pr merge --squash --delete-branch. The merge pushes to main and starts another run.
Part B: Trigger a run by hand
  1. Switch to main and sync:
    git checkout main
    git pull
  2. Start the workflow without a new commit:
    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.

  3. Note the run ID from gh run list.
Checkpoint: the manually started run finishes green.

gh workflow run says the workflow was not found: the file must exist on the default branch. Confirm that main contains ci.yml.

Common failures and where to look

SymptomLikely causeWhere to look
No such file: requirements.txtFile not committed or wrong foldergit status, git ls-files
Smoke test: connection refusedServer not ready after sleepIncrease sleep, or poll /health in a loop
Address already in usePort 8000 taken locally or on the runnerSet PORT, or stop the old process
ModuleNotFoundError: apppytest run from a different directoryRun pytest from the repo root
Workflow not appearingFile not in .github/workflows, or invalid YAMLCheck the path and indentation

Stretch exercises

Add a route

Add GET /version that returns a version string from an environment variable, with a test.

Cache dependencies

Add cache: pip to the setup-python step and compare run times.

Add a Dockerfile

Build the image in a second job and run the smoke test against the container.

Coverage gate

Run pytest --cov and fail the build below a chosen percentage.

Wrap-up

Questions and discussion