DEV Community

Cover image for Building a Bulletproof Python CI Pipeline with GitHub Actions, DevPod & Mise (+ the Real-World Bugs We Squashed Along the Way)
Alan Varghese
Alan Varghese

Posted on

Building a Bulletproof Python CI Pipeline with GitHub Actions, DevPod & Mise (+ the Real-World Bugs We Squashed Along the Way)

A complete guide to building reproducible, containerized Python CI pipelines using DevPod, Mise, Ruff, pytest, and GitHub Actionsβ€”featuring hard-won lessons from real debugging sessions.

Have you ever uttered the classic developer refrain: "Well, it works on my machine!"?

We have all been there. You write a clean Python script, write a few unit tests that pass locally, push to GitHub, and immediately get greeted by a glowing red ❌ in your CI pipeline. Or even worse: your CI passes with flying colors, only for production to blow up because your CI runner was secretly masking environment mismatches.

In this guide, we will walk through building an end-to-end, production-ready Continuous Integration (CI) pipeline for a Python project from scratch. We combine:

  • 🐳 DevPod & Docker: For fully isolated, reproducible local container environments.
  • βš™οΈ Mise-en-place (Mise): For declarative, deterministic tool versioning across local and CI.
  • ⚑ Ruff: For blazingly fast linting and auto-formatting.
  • πŸ§ͺ pytest: For rock-solid unit testing.
  • πŸš€ GitHub Actions: For automated quality gates on every push.

More importantly, rather than showing only the "happy path," we will dissect the real-world edge cases and debugging hurdles encountered along the wayβ€”including Python import path traps, monorepo workflow discovery quirks, toolchain vs. interpreter disconnects, and why you should always deliberately break your CI to verify its integrity.


πŸ› The Architecture: How It All Fits Together

A robust development lifecycle requires a clear separation of concerns. Developer ergonomics shouldn't bleed into project dependencies, and local environments should mirror CI as closely as possible.

+-------------------------------------------------------------------------+
|                       LOCAL DEVELOPMENT (DevPod)                        |
|                                                                         |
|  Developer  ──>  DevPod / Docker Container                              |
|                         β”œβ”€β”€ Dotfiles (Personal Shell / Theme)           |
|                         └── Mise (Declared Tools in mise.toml)          |
|                                  β”œβ”€β”€ Python 3.12+                       |
|                                  β”œβ”€β”€ Ruff (Linter/Formatter)            |
|                                  └── pytest (Test Runner)               |
|                                                                         |
|  Local Quality Checks: ruff check .  &&  pytest -vv                     |
+-------------------------------------------------------------------------+
                                   β”‚
                               git push
                                   β–Ό
+-------------------------------------------------------------------------+
|                       REMOTE CI (GitHub Actions)                        |
|                                                                         |
|  GitHub Actions Runner (Ubuntu Latest)                                  |
|         β”œβ”€β”€ 1. Checkout Repository                                      |
|         β”œβ”€β”€ 2. Install Mise (`jdx/mise-action`)                         |
|         β”œβ”€β”€ 3. `mise install` (Identical Tool Versions)                 |
|         β”œβ”€β”€ 4. Run Ruff (`mise exec -- ruff check .`)                   |
|         └── 5. Run pytest (`mise exec -- pytest -vv`)                   |
|                                                                         |
|  CI Verdict: PASS βœ… or FAIL ❌                                          |
+-------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Separation of Concerns

Layer Responsibility Key Files
Dotfiles Developer ergonomics, shell prompt, aliases, personal editor config .zshrc, .bashrc, personal Git config
Dev Container OS-level base image, container runtime Dockerfile, .devcontainer.json
Mise Language versions, linters, package runners mise.toml
Application Business logic, module definitions src/, pytest.ini
Test Suite Unit tests, edge case assertions tests/
CI / CD Automated validation, branch protection rules .github/workflows/github-actions-ci.yml

πŸ“‚ 1. Setting Up the Project Structure

Let's start with a clean directory layout:

github-actions-devpod-ci/
β”œβ”€β”€ .devcontainer.json         # Dev container configuration
β”œβ”€β”€ Dockerfile                 # Container image definition with Mise
β”œβ”€β”€ .gitignore                 # Cache & environment ignores
β”œβ”€β”€ mise.toml                  # Declarative tool specifications
β”œβ”€β”€ pytest.ini                 # Python path configuration
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ __init__.py
β”‚   └── main.py                # Application logic
└── tests/
    └── test_main.py           # Automated unit tests
Enter fullscreen mode Exit fullscreen mode

The Application Code (src/main.py)

A simple arithmetic module with explicit error handling:

def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b


def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b


if __name__ == "__main__":
    print("GitHub Actions DevPod CI Demo")
    print("2 + 3 =", add(2, 3))
Enter fullscreen mode Exit fullscreen mode

The Test Suite (tests/test_main.py)

from src.main import add, divide, multiply, subtract


def test_add():
    assert add(2, 3) == 5


def test_subtract():
    assert subtract(5, 3) == 2


def test_multiply():
    assert multiply(4, 3) == 12


def test_divide():
    assert divide(10, 2) == 5


def test_divide_by_zero():
    try:
        divide(10, 0)
        assert False
    except ValueError:
        assert True
Enter fullscreen mode Exit fullscreen mode

πŸ›  2. Deterministic Tool Management with Mise

Instead of relying on whatever version of Python or linters happen to be installed on your host machine, we use Mise to declare exact tooling requirements in mise.toml:

[tools]
python = "latest"
"pipx:ruff" = "latest"
"pipx:pytest" = "latest"
pipx = "latest"
Enter fullscreen mode Exit fullscreen mode

Why Mise + pipx?

Mise allows you to manage programming language runtimes (like asdf or nvm) and standalone CLI tools (via pipx, cargo, npm, or standalone binaries) in a single declarative configuration file.

When a developer joins your project or the CI runner fires up, running:

mise install
Enter fullscreen mode Exit fullscreen mode

installs everything in one shot. No manual virtualenv activation dance, no missing binary surprises.


🐳 3. Local Development with DevPod & Dev Containers

To ensure every developer works in a clean, reproducible container, we define a lightweight Dockerfile and .devcontainer.json.

Dockerfile

FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04

# Install Mise binary
COPY --from=jdxcode/mise /usr/local/bin/mise /usr/local/bin/

# Activate Mise automatically in both bash and zsh shells
RUN echo 'eval "$(mise activate bash)"' >> /home/vscode/.bashrc && \
    echo 'eval "$(mise activate zsh)"' >> /home/vscode/.zshrc
Enter fullscreen mode Exit fullscreen mode

.devcontainer.json

{
  "build": {
    "context": "..",
    "dockerfile": "Dockerfile"
  }
}
Enter fullscreen mode Exit fullscreen mode

You can spin up this environment locally using DevPod:

devpod up . --provider docker
Enter fullscreen mode Exit fullscreen mode

Once inside the container, personal dotfiles give you your favorite shell customizations, while the project repository provides the exact tooling and application dependencies.


πŸš€ 4. The GitHub Actions CI Pipeline

Now let's automate our checks. In a multi-project or monorepo workspace (e.g., lab/github-actions-devpod-ci), our workflow file lives at .github/workflows/github-actions-ci.yml at the repository root.

name: GitHub Actions DevPod CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  ci-pipeline:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: github-actions-devpod-ci

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Mise
        uses: jdx/mise-action@v2
        with:
          version: latest
          install: false

      - name: Install Project Tools
        run: mise install

      - name: Run Ruff Linting
        run: mise exec -- ruff check .

      - name: Run pytest Test Suite
        run: mise exec -- pytest -vv
Enter fullscreen mode Exit fullscreen mode

Key Workflow Highlights:

  1. actions/checkout@v4: Pulls the repository code.
  2. jdx/mise-action@v2: Sets up Mise directly on the GitHub Actions runner.
  3. defaults.run.working-directory: Ensures every run step executes inside the specific subproject folder where mise.toml and pytest.ini reside.
  4. mise exec -- <command>: Runs ruff and pytest using the exact tool versions managed by Mise.

πŸ” The Debugging Field Guide: 7 Real-World Traps & How We Solved Them

Building pipelines in documentation looks effortless; building them in real projects always exposes subtle edge cases. Here are the real bugs encountered during implementation and the architectural principles behind fixing them.


Trap #1: "Command Not Found: pytest" (Explicit vs. Implicit Tooling)

  • The Symptom: After running mise install, trying to run pytest returned command not found.
  • The Root Cause: We assumed pytest would be installed by default because Python was present. But mise.toml only declared python = "latest".
  • The Fix: Explicitly declare tool requirements in mise.toml:
  "pipx:pytest" = "latest"
Enter fullscreen mode Exit fullscreen mode
  • Core Takeaway: Never rely on ambient system tools. If your build or test process requires a binary, declare it explicitly in your version control configuration.

Trap #2: Binary Exists vs. Module Exists (which pytest vs. python -m pytest)

  • The Symptom: which pytest showed /home/vscode/.local/share/mise/installs/pipx-pytest/..., yet running python -m pytest resulted in No module named pytest.
  • The Root Cause: Standalone tools installed via pipx or Mise binary shims live in their own isolated virtual environment. The pytest executable is available on $PATH, but it is not installed as an importable package inside the main Python interpreter environment.
  • The Fix: Standardize on executing the standalone test runner binary directly (pytest -vv or mise exec -- pytest), rather than assuming python -m pytest inside an unbundled interpreter.

Trap #3: ModuleNotFoundError: No module named 'src'

  • The Symptom: Running pytest threw:
  ModuleNotFoundError: No module named 'src'
Enter fullscreen mode Exit fullscreen mode

even though from src.main import add was written in tests/test_main.py.

  • The Diagnosis: Running a one-liner proved Python could import the module fine when invoked from the root:
  python -c "from src.main import add; print(add(2, 3))"
  # Output: 5
Enter fullscreen mode Exit fullscreen mode

The issue was that pytest does not automatically add the current working directory to sys.path during test execution unless configured.

  • The Bad Workaround (Avoid This!): Copying the src/ folder into tests/. This makes tests pass temporarily but introduces duplicate code and destroys single-source-of-truth architecture.
  • The Clean Architectural Solution: Create a standard pytest.ini in your project root:
  [pytest]
  pythonpath = .
Enter fullscreen mode Exit fullscreen mode

This cleanly tells pytest to treat the project root as part of the module search path.


Trap #4: The Relative Import Trap (from .src.main import ...)

  • The Symptom: When trying to fix the import error above, changing the import in tests/test_main.py to:
  from .src.main import add  # ❌ INCORRECT
Enter fullscreen mode Exit fullscreen mode

resulted in:

  ModuleNotFoundError: No module named 'tests.src'
Enter fullscreen mode Exit fullscreen mode
  • The Root Cause: A leading dot (.) designates a relative import within the current package (tests). Python searched for tests.src, which does not exist.
  • The Solution: Use standard absolute imports from the project root:
  from src.main import add, divide, multiply, subtract  # βœ… CORRECT
Enter fullscreen mode Exit fullscreen mode

Trap #5: Ruff Linting vs. Guesswork

  • The Symptom: Ruff flagged multi-line import formatting in test files.
  • The Anti-Pattern: Spending 15 minutes manually reordering lines and guessing what the linter expects.
  • The Solution: Let Ruff fix safe formatting and import sorting automatically:
  # Automatically fix safe rule violations
  ruff check . --fix

  # Verify clean state
  ruff check .
Enter fullscreen mode Exit fullscreen mode

Trap #6: Monorepo Workflow Discovery in GitHub Actions

  • The Symptom: You push commits containing .github/workflows/ci.yml inside a nested subproject (my-repo/subproject/.github/workflows/ci.yml), but GitHub Actions never triggers!
  • The Root Cause: GitHub Actions only scans the root directory of the repository for workflow files (.github/workflows/*.yml). Subdirectory workflow files are completely ignored.
  • The Solution: Place your workflow file at the repository root and use defaults.run.working-directory:
# Located at: .github/workflows/github-actions-ci.yml
jobs:
  ci-pipeline:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: github-actions-devpod-ci
Enter fullscreen mode Exit fullscreen mode

Trap #7: Git Index & Monorepo History Mismatches

  • The Symptom: When staging subproject changes in a repository with existing commits, Git reported No commits yet or threw index conflicts.
  • The Fix: Never delete .git in frustration! Instead, inspect remote state and align:
  git status
  git remote -v
  git log --oneline --all --max-count=5
  git fetch origin
  git reset origin/main
  git restore <unmodified-sibling-folders>
  git add <project-folder>
  git commit -m "feat: add CI pipeline"
Enter fullscreen mode Exit fullscreen mode

πŸ’₯ 5. Validating CI with Intentional Failure Injection

Here is an uncomfortable truth: A CI pipeline that only ever passed is not a proven CI pipeline.

How do you know your pipeline isn't returning a false positive because of an incorrect exit code or a misconfigured test glob?

To prove our CI pipeline actually guards the codebase, we performed a deliberate Failure Injection Test:

Step 1: Inject a Failing Assertion

In tests/test_main.py, we temporarily added:

def test_ci_failure():
    # Intentionally broken test: 2 + 2 != 5
    assert add(2, 2) == 5
Enter fullscreen mode Exit fullscreen mode

Step 2: Push to GitHub & Inspect the Run

GitHub Actions immediately triggered:

  • βœ… Checkout repository: PASSED
  • βœ… Install Mise: PASSED
  • βœ… Install Project Tools: PASSED
  • βœ… Run Ruff Linting: PASSED
  • ❌ Run pytest Test Suite: FAILED (Exit code 1)
FAILED tests/test_main.py::test_ci_failure - assert 4 == 5
========================= 1 failed, 5 passed in 0.04s =========================
Error: Process completed with exit code 1.
Enter fullscreen mode Exit fullscreen mode

Step 3: Revert and Observe Recovery

After deleting the broken test and pushing again, the pipeline turned green (5/5 tests passed).

Rule of Thumb: Before shipping any CI configuration, always break a test or lint rule on purpose to ensure the pipeline halts execution with a non-zero exit code.


🧭 The 10-Step Systematic Troubleshooting Protocol

When debugging containerized environments, Python import errors, or CI runner failures, don't guess. Follow this structured protocol:

 1. Read the full error message and traceback (don't skim just the last line).
 2. Identify the exact command and working directory that failed.
 3. Determine which layer is failing (Host, Docker, DevPod, Mise, Python, Git, or CI).
 4. Verify binary paths (`which <tool>`, `mise which <tool>`).
 5. Verify runtime versions (`<tool> --version`, `python --version`).
 6. Isolate and test the smallest reproducible unit (e.g. `python -c "from src.main import ..."`).
 7. Make exactly ONE change at a time.
 8. Re-run the command under identical conditions.
 9. Confirm the fix preserves architectural clean boundaries (no copy-pasting code into test dirs!).
10. Proceed only after the root cause is resolved and verified.
Enter fullscreen mode Exit fullscreen mode

🏁 Summary & Key Takeaways

  1. Explicit is better than implicit: Declare all runtimes and tools in mise.toml rather than assuming host availability.
  2. Decouple your layers: Keep personal dotfiles, container configurations, tool definitions, and application code in their proper places.
  3. Use pytest.ini for import path resolution: Avoid import path hacks or relative submodule mangling.
  4. Mind monorepo pathing in GitHub Actions: Workflows must live in .github/workflows/ at the repository root, but commands can target subdirectories via working-directory.
  5. Always test failure modes: A quality gate is only reliable when it has been observed catching real errors.

Useful Commands Cheat Sheet

# Mise Management
mise install                # Install all tools in mise.toml
mise ls                     # List installed tools and active versions
mise which python           # Inspect active Python binary path

# Local Quality Verification
ruff check .                # Lint checks
ruff check . --fix          # Auto-fix safe lint errors
pytest -vv                  # Verbose test run
pytest --collect-only -vv   # Inspect test discovery without execution

# Python One-Liner Sanity Check
python -c "from src.main import add; print(add(2, 3))"
Enter fullscreen mode Exit fullscreen mode

[github]
[linkedin]

Have you experienced tricky import bugs or CI configuration headaches in your Python projects? How do you manage tool versioning across your team? Drop your thoughts and experiences in the comments below!

Top comments (0)