Shipping Fast on a Side Project: From 10-Minute CI to Instant Deploys

How I cut my CI pipeline from 10.5 minutes to 7 minutes with parallel tests, pip caching, and path filters, plus the Chart.js debugging saga and the Jinja2 filter that only broke on CI.

Shipping Fast on a Side Project: From 10-Minute CI to Instant Deploys

Every time I pushed a commit to Life Hub, I waited ten and a half minutes. Run tests. Build image. Push to GHCR. Wait for Flux to reconcile. Wait for the pod to roll. Open the page. Check if it worked.

When you are iterating on a feature and deploying six times in a session, ten minutes per cycle means an hour of dead time. I finally got annoyed enough to fix it.

The Starting Point

The CI pipeline was straightforward GitHub Actions: checkout, setup Python, install dependencies, lint, run 2,500+ tests serially, build Docker image, push to GHCR. Two jobs (test, build) running sequentially. Pip installed from scratch every time. Ruff blocked the test step. Everything serial.

Optimization 1: Parallel Tests with pytest-xdist

2,500 tests running on a single core is the most obvious bottleneck. pytest-xdist splits test execution across multiple workers.

pytest tests/ -n auto --dist worksteal

The worksteal strategy is key. Unlike loadfile (distributes whole files) or loadscope (distributes by module), worksteal lets idle workers steal pending tests from busy workers’ queues. This maximizes CPU utilization when test durations vary: some Life Hub tests hit async database operations and take 500ms, others are pure unit tests completing in 2ms.

Locally with 10 cores: 112 seconds dropped to 36 seconds. A 3x speedup. On CI with 2 cores: 7.5 minutes dropped to about 6.25 minutes.

The Worker Isolation Problem

The first parallel run produced flaky failures. Different tests on different occasions, always involving database state. The cause: every xdist worker shares the same process, and the conftest creates one temp database file at import time.

# Before: all workers share one DB
_DB_FILE = tempfile.mktemp(suffix=".db", prefix="life-hub-test-")

# After: each worker gets its own DB
_worker = os.environ.get("PYTEST_XDIST_WORKER", "main")
_DB_FILE = tempfile.mktemp(suffix=f"-{_worker}.db", prefix="life-hub-test-")

xdist sets PYTEST_XDIST_WORKER to "gw0", "gw1", etc. in each forked worker. Appending it to the database filename gives each worker full isolation. Zero flaky tests after this change.

Optimization 2: Concurrent Lint

Ruff takes 3 seconds but was running inside the test job, blocking 2,500 tests. Splitting it into its own parallel job eliminates this serial dependency entirely.

Optimization 3: Pip Caching

Installing 18 dependencies from scratch on every CI run is wasteful. The setup-python action has built-in pip caching with cache: pip. First run populates the cache. Subsequent runs restore and only install changed packages. Saves 30-60 seconds per run.

Optimization 4: Path Filtering

Not every push changes code. Sometimes I edit CLAUDE.md, update documentation, or tweak a config file. Running 2,500 tests for a README change is wasteful.

Using dorny/paths-filter, the pipeline detects whether the push touched code files (app/**, tests/**, requirements.txt, Dockerfile). Documentation-only pushes skip lint and test entirely, going straight to Docker build. 10 minutes becomes 1.5 minutes.

The Results

MetricBeforeAfter
Total CI (code changes)10.5 min7 min
Total CI (docs only)10.5 min1.5 min
Test step9.5 min6.25 min
Docker build1 min35s (cached)

A 33% reduction on code changes and 86% reduction on docs changes.

The Debugging Side Quests

Chart.js vs Alpine.js: A Proxy War

Life Wheel uses Chart.js 4.4.7 for its radar chart and Alpine.js 3.14 for state management. This combination has a specific failure mode that cost me hours.

Chart.js fires animation frames during chart construction, before chartArea is calculated. The beforeDatasetDraw plugin hook crashes with TypeError: Cannot read properties of null (reading ‘save’) because it tries to call ctx.save() on a null canvas context.

The fix required patching Chart.prototype.draw() with a null guard:

(function() {
    if (typeof Chart === "undefined" || Chart._drawPatched) return;
    const origDraw = Chart.prototype.draw;
    Chart.prototype.draw = function() {
        if (!this.chartArea) return;
        origDraw.apply(this, arguments);
    };
    Chart._drawPatched = true;
})();

But there was a second issue. Alpine.js wraps reactive data in Proxy objects. When you pass Alpine-proxied data to chart.update(), Chart.js’s internal option resolver tries to walk the proxy chain, which triggers Alpine’s reactivity system, which triggers Chart.js to re-resolve options, which triggers Alpine again. Infinite recursion, stack overflow.

The solution: never call chart.update() with Alpine data. Destroy and recreate the chart instead. More DOM work, zero recursion risk.

The Jinja2 Filter That Only Broke on CI

The Fitness page had this template code:

style="width: {{ [pct, 100] | min }}%;"

It worked perfectly on my local machine (Python 3.14, Jinja2 3.1.5). It crashed on CI (Python 3.12) with TypeError: ‘float’ object is not iterable. The min filter internally calls iter() on its input, and the behavior differs between Jinja2 versions when the input is a list literal constructed from a template variable.

The fix is embarrassingly simple:

style="width: {{ pct if pct <= 100 else 100 }}%;"

No filter, no version-dependent behavior, no ambiguity. Sometimes the right fix is the boring one.

Why This Matters for a Side Project

The common advice is that side projects do not need CI discipline. Just push to main, fix it if it breaks, move fast.

That works until it does not. Life Hub runs on Kubernetes with GitOps: every push to main triggers a real deployment to a real cluster serving real data. There is no staging environment. If tests do not catch a regression, I find it at 7 AM when my morning briefing is blank.

More importantly, slow CI creates a psychological barrier to shipping. If a deploy takes 10 minutes, you batch changes. Batched changes are harder to debug when something breaks. You start pushing less often. Feature velocity drops. The project stalls.

Fast CI creates a virtuous cycle: push often, catch problems early, iterate quickly, stay motivated. Shaving 3 minutes off each cycle is not about the 3 minutes. It is about removing the friction that makes you say "I will push it later" instead of "let me ship this now."

Invest in the pipeline. It pays compound interest.

This is the final post in a 4-part series on evolving Life Hub from a feature collection into a product. Start from the beginning: The Frankenstein Problem.