Pipeline Performance & Optimisation
Why Pipeline Speed Matters
A 30-minute pipeline discourages small commits. Developers batch changes to avoid waiting. Batched changes = larger PRs = harder reviews = more bugs.
Target: main branch pipeline under 10 minutes.
Parallelisation
Parallel Jobs
Jobs without dependencies run simultaneously:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: uv run ruff check .
type-check:
runs-on: ubuntu-latest
steps:
- run: uv run mypy .
unit-tests:
runs-on: ubuntu-latest
steps:
- run: uv run pytest -m unit -n auto
# All three above run in parallel
integration-tests:
needs: [lint, type-check, unit-tests] # gates wait for all three
runs-on: ubuntu-latest
steps:
- run: uv run pytest -m integration -n auto
Parallel Test Workers
- run: uv run pytest -n auto # uses all available CPU cores
-n auto detects CPU count and spawns that many workers. For a 4-core runner: 4x speedup.
Test Matrix Sharding
Split test suite across multiple parallel runners:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: |
uv run pytest \
--shard-id=${{ matrix.shard }} \
--num-shards=4 \
-n auto
4 runners × 4 workers each = 16× parallelism for the test stage.
Caching
Dependency Cache
- uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: uv-${{ runner.os }}-
Impact: uv sync goes from 60s → 2s on cache hit.
Docker Layer Cache
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
Docker layer cache: rebuilding an image after a code-only change skips all dependency layers. Full rebuild: 90s. Cache hit (code change only): 15s.
Build Output Cache
- uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ hashFiles('src/**/*.py') }}
Mypy caches type check results. Re-checking unchanged files: milliseconds.
Selective Execution (Path Filtering)
Only run pipelines when relevant files change:
on:
push:
paths:
- 'src/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/**'
Changing README.md does not trigger the full test pipeline.
For monorepos, run per-service pipelines:
on:
push:
paths:
- 'services/orders/**'
- 'shared/models/**'
Pipeline Duration Targets
| Stage | Target | Tools |
|---|---|---|
| Lint + type check | < 1 min | ruff, mypy with cache |
| Unit tests | < 2 min | pytest -n auto |
| Integration tests | < 5 min | pytest -n auto + services in Docker |
| Build Docker image | < 3 min | Layer cache |
| API tests (staging) | < 5 min | pytest -n auto |
| E2E smoke | < 3 min | playwright, 10–15 tests max |
| Total PR pipeline | < 10 min | |
| Total deploy pipeline | < 15 min |
Pipeline Metrics to Track
- name: Annotate with timing
if: always()
run: |
echo "Stage completed in ${{ steps.tests.outputs.duration }}s"
echo "Cache hit: ${{ steps.cache.outputs.cache-hit }}"
Run a weekly review: which stage is slowest? What is its trend? A stage that grew from 2min → 8min over three months needs attention.