GitHub Actions ile Python CI Pipeline

Selamlar, bu yazımda Python projelerinde GitHub Actions ile sağlam bir CI pipeline'ı nasıl kurduğumu adım adım anlatacağım. Konu basit gibi duruyor ama detaylar var: matrix build, pip cache, ruff/mypy/pytest sıralaması, coverage upload, yeniden kullanılabilir workflow ve branch protection. Hepsini bir arada görünce iş netleşiyor. Hadi başlayalım.

CI'ın asıl işi bence çok basit: hatayı production'a düşmeden yakalamak. Lokalde her şey yeşilken merge'lenen bir PR prod'da patlıyorsa, eksik olan parça genellikle CI'da bir kontroldür. O yüzden minimum şu üçü her zaman olmalı: test, lint, type check.

Temel workflow iskeleti

Önce sade bir .github/workflows/ci.yml yazalım. Burada amacımız; her push ve PR'da Python'un birden fazla versiyonunda testleri çalıştırmak.

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
          cache-dependency-path: requirements*.txt
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: pytest -q

Burada dikkat çekmek istediğim iki şey var. Birincisi matrix.python-version: kütüphane geliştiriyorsanız 3 versiyon test etmek pahalı değil ama ileride ortaya çıkacak uyumsuzlukları erkenden yakalıyor. İkincisi cache: 'pip': setup-python@v5 bunu kendi başına yapıyor, ekstra bir actions/cache adımına gerek yok. Yani 'pip cache nasıl kurulur?' diye uzun uzun yazmak zorunda değilsiniz, tek satır.

Lint ve type check

Test'in tek başına bir anlamı yok. Ben genelde lint'i ve type check'i ayrı job'lar olarak koşturuyorum çünkü hızlılar ve test job'ı uzun sürdüğünde önce bu ikisinden geri dönüş alıyorum.

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      - run: pip install ruff mypy
      - run: ruff check .
      - run: ruff format --check .
      - run: mypy src/

Ruff'un güzel yanı; flake8, isort, pyupgrade gibi araçların yaptığı işin büyük kısmını tek binary altında, üstelik çok hızlı yapıyor. Bence yeni başlayan bir proje için varsayılan tercih artık bu. mypy'ı da src/ altına yönlendirip testleri dışarıda tuttum, çünkü test kodunda strict mode bazen gereksiz gürültü çıkarıyor.

Coverage ve eşik

Test geçti diye işin bitmiyor; kapsamı da görmek lazım. pytest-cov ile XML çıktı üretip Codecov'a gönderiyoruz. Eşiği --cov-fail-under ile build tarafında zorluyoruz, böylece coverage düştüğünde PR kırmızı oluyor.

      - run: pip install pytest pytest-cov
      - run: pytest --cov=src --cov-report=xml --cov-fail-under=80
      - if: matrix.python-version == '3.12'
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml
          token: ${{ secrets.CODECOV_TOKEN }}

Buradaki if: matrix.python-version == '3.12' koşulu önemli: matrix'te 3 versiyon var ama Codecov'a tek seferde göndermek yetiyor. Yoksa aynı rapor üç kere yükleniyor ve dashboard kafa karıştırıcı oluyor.

Yeniden kullanılabilir workflow

Birden fazla repo'nuz varsa aynı CI'ı her birine kopyala-yapıştır yapmak işkence. Bunun yerine workflow_call ile tek bir 'çekirdek' workflow yazıp diğer repolardan çağırabilirsiniz.

on:
  workflow_call:
    inputs:
      python-version:
        type: string
        default: '3.12'

Çağıran tarafta ise tek satır:

jobs:
  ci:
    uses: my-org/.github/.github/workflows/python-ci.yml@main
    with:
      python-version: '3.12'

Tecrübeyle sabit; üç-beş projeden sonra bu yapı kendini fazlasıyla amorti ediyor.

Branch protection

CI'ın gerçekten işe yaraması için kontrolün main'e doğrudan push'lanmasını engellemesi lazım. GitHub'ın Settings > Branches ekranından main için 'Require status checks to pass before merging' işaretleyin ve yukarıdaki lint, test, type-check job adlarını seçin. PR yeşil olmadan merge düğmesi tıklanamaz hâle gelir; ben buna 'CI'ın dişlerinin çıkması' diyorum.

Sık karşılaşılan hatalar

  • Cache anahtarını lockfile'a bağlamamak: requirements*.txt değişmediği sürece cache hit alıyorsunuz; ama lock dosyanız varsa onu da cache-dependency-path listesine ekleyin, yoksa eski paketler takılı kalıyor.
  • Tek bir job'da her şeyi koşturmak: lint 5 saniyede biten bir şey, test 4 dakika sürebilir. Aynı job'a koyarsanız ufacık bir format hatası için 4 dakika bekliyorsunuz. Job'ları ayırın, paralel çalışsınlar.
  • fail-fast: false koymamak: matrix'te 3.11 patladığı an diğer versiyonlar iptal oluyor. Versiyona özgü bir bug varsa sadece o satırı görmek istersiniz; strategy.fail-fast: false ekleyin.
  • Secret'ı log'a sızdırmak: echo "$TOKEN" gibi bir şey yazmayın, GitHub maskeliyor ama her zaman değil. Sırrı doğrudan action'a parametre olarak verin.
  • Branch protection'ı unutmak: CI'ı kurup ayarlardan zorunlu kılmadıysanız, kimse aldırmıyor. Pipeline'ın anlamı burada bitiyor.

Kapanış

Bu yazıda Python için GitHub Actions üzerinde minimum kabul edilebilir bir CI'ı kurduk: matrix build, pip cache, ruff, mypy, pytest, coverage ve yeniden kullanılabilir workflow. Bence başlangıç için bu yeter; sonradan security scan, container build, release otomasyonu gibi parçalar zaten doğal olarak gelir. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.