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 β |
+-------------------------------------------------------------------------+
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
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))
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
π 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"
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
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
.devcontainer.json
{
"build": {
"context": "..",
"dockerfile": "Dockerfile"
}
}
You can spin up this environment locally using DevPod:
devpod up . --provider docker
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
Key Workflow Highlights:
-
actions/checkout@v4: Pulls the repository code. -
jdx/mise-action@v2: Sets up Mise directly on the GitHub Actions runner. -
defaults.run.working-directory: Ensures everyrunstep executes inside the specific subproject folder wheremise.tomlandpytest.inireside. -
mise exec -- <command>: Runsruffandpytestusing 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 runpytestreturnedcommand not found. -
The Root Cause: We assumed pytest would be installed by default because Python was present. But
mise.tomlonly declaredpython = "latest". -
The Fix: Explicitly declare tool requirements in
mise.toml:
"pipx:pytest" = "latest"
- 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 pytestshowed/home/vscode/.local/share/mise/installs/pipx-pytest/..., yet runningpython -m pytestresulted inNo module named pytest. -
The Root Cause: Standalone tools installed via
pipxor 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 -vvormise exec -- pytest), rather than assumingpython -m pytestinside an unbundled interpreter.
Trap #3: ModuleNotFoundError: No module named 'src'
-
The Symptom: Running
pytestthrew:
ModuleNotFoundError: No module named 'src'
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
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 intotests/. This makes tests pass temporarily but introduces duplicate code and destroys single-source-of-truth architecture. -
The Clean Architectural Solution: Create a standard
pytest.iniin your project root:
[pytest]
pythonpath = .
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.pyto:
from .src.main import add # β INCORRECT
resulted in:
ModuleNotFoundError: No module named 'tests.src'
-
The Root Cause: A leading dot (
.) designates a relative import within the current package (tests). Python searched fortests.src, which does not exist. - The Solution: Use standard absolute imports from the project root:
from src.main import add, divide, multiply, subtract # β
CORRECT
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 .
Trap #6: Monorepo Workflow Discovery in GitHub Actions
-
The Symptom: You push commits containing
.github/workflows/ci.ymlinside 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
Trap #7: Git Index & Monorepo History Mismatches
-
The Symptom: When staging subproject changes in a repository with existing commits, Git reported
No commits yetor threw index conflicts. -
The Fix: Never delete
.gitin 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"
π₯ 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
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.
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.
π Summary & Key Takeaways
-
Explicit is better than implicit: Declare all runtimes and tools in
mise.tomlrather than assuming host availability. - Decouple your layers: Keep personal dotfiles, container configurations, tool definitions, and application code in their proper places.
-
Use
pytest.inifor import path resolution: Avoid import path hacks or relative submodule mangling. -
Mind monorepo pathing in GitHub Actions: Workflows must live in
.github/workflows/at the repository root, but commands can target subdirectories viaworking-directory. - 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))"
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)