Mert Özen Her Satırda Daha İleri
DevOps GitHub Actions — #04

GitHub Actions'ta Job'lar: needs, matrix, concurrency, timeout ve Service Container'lar

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
GitHub Actions'ta Job'lar: needs, matrix, concurrency, timeout ve Service Container'lar

Job'lar varsayılan olarak paralel koşar; needs ile bir DAG kurulur, kırılan job'a bağlı her şey skipped olur. Bookstore CI'ını format, unit-test (OS matrix), integration-test (PostgreSQL 16/17/18 service container), web-build ve publish job'larına bölüp act ile koşturuyor; include/exclude, fail-fast, max-parallel, concurrency, timeout-minutes, continue-on-error ve Nisan 2026'da gelen service command/entrypoint'i inceliyoruz.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), PostgreSQL 16/17/18 (alpine image'ları), Node 24 ve Angular 22 ile denenmiştir. Workflow'lar act ile yerelde koşuldu; GitHub'da koşulmadı. Kasım 2026 itibarıyla geçerli bilgilerdir.

Önce Tahmin Et

Bookstore'un CI'ı (Continuous Integration) artık tek job değil, beş job'lık bir graf: format, unit-test, integration-test, web-build ve hepsinin sonunda publish. Bağımlılıklar şöyle:

jobs:
  format:
    # ...
  unit-test:
    needs: format
  integration-test:
    needs: unit-test
  web-build:
    # needs yok
  publish:
    needs: [integration-test, web-build]

Ada kargo ücretini değiştirip testleri güncellemeyi yine unutuyor ve unit-test kırmızıya dönüyor. Soru: web-build koşar mı? integration-test ve publish ne olur, "failed" mı görünür, "skipped" mı?

$ act push --matrix os:ubuntu-latest
[ci/web-build] ⭐ Run Main npm run build
[ci/web-build] 🏁  Job succeeded
[ci/format   ] 🏁  Job succeeded
[ci/unit-test] ⭐ Run Main dotnet test --project api/tests/Bookstore.Api.Tests -c Release
[ci/unit-test]   |   Expected: 29.90
[ci/unit-test]   |   Actual:   24.90
[ci/unit-test]   | Test run summary: Failed!
[ci/unit-test]   ❌  Failure - Main dotnet test --project api/tests/Bookstore.Api.Tests -c Release [5.374225834s]
[ci/unit-test] 🏁  Job failed
Error: Job 'unit-test' failed

web-build koştu ve yeşil bitti, çünkü kimseye bağlı değil; format ile aynı anda başladı. integration-test ve publish ise hiç başlamadı. act burada run'ı kırılan job'da durduruyor; GitHub ise o job'ları "skipped" olarak gösterir. Dokümantasyonun kuralı: bir job başarısız olur ya da atlanırsa, ona needs ile bağlı bütün job'lar da atlanır ve bu, zincirin o noktasından sonrasına yayılır. Yani publish "failed" değil "skipped" görünür; branch protection'da required check olarak publish'i seçtiysen bunu bilmen önemli. Bu yazıda job'ları bir grafa dönüştürmenin araçlarına bakıyoruz: needs, matrix, service container, concurrency ve timeout.

Job Grafı: needs ve Job Output'ları

Bir workflow'daki job'lar varsayılan olarak paralel koşar; needs bir job'ı başka job'ların başarıyla bitmesine bağlar. Böylece bir DAG (Directed Acyclic Graph, yönlü döngüsüz graf) oluşur. Bookstore CI'ının grafı:

04-job-graph-tr.png

act aynı grafı stage'lere bölerek gösteriyor:

$ act -l
Stage  Job ID            Job name          Workflow name  Workflow file  Events
0      web-build         web-build         ci             ci.yml         push,pull_request
0      format            format            ci             ci.yml         push,pull_request
1      unit-test         unit-test         ci             ci.yml         pull_request,push
2      integration-test  integration-test  ci             ci.yml         pull_request,push
3      publish           publish           ci             ci.yml         push,pull_request

web-build ile format aynı stage'de, yani aynı anda başlıyor. Angular build'i .NET testlerini beklemek zorunda değil; bu paralellik toplam süreyi doğrudan kısaltır. publish ise iki dalın da bitmesini bekliyor. Grafı tasarlarken sorulacak soru şu: "Bu job gerçekten öncekinin sonucuna mı ihtiyaç duyuyor, yoksa ben alışkanlıkla mı sıraya dizdim?" format ucuz ve hızlı bir kontrol olduğu için önde; formatı bozuk bir commit için on dakikalık test makinesi açmaya gerek yok.

Job'lar arasında küçük değerler output'larla taşınır. publish job'ı bir sürüm numarası üretiyor ve onu job output'u olarak açıyor:

  publish:
    needs: [integration-test, web-build]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - id: version
        run: echo "version=1.4.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
      - run: echo "would publish Bookstore ${{ steps.version.outputs.version }}"

Bu job'a needs ile bağlanan başka bir job (ör. deploy) değeri needs.publish.outputs.version ile okur. Önemli kural: bir job yalnız needs'te adı geçen job'ların output'larını görebilir; zincirin iki halka gerisindeki job'ın output'una doğrudan erişemezsin, ara job'ın onu kendi output'u olarak taşıması gerekir.

Matrix: Kombinasyonlar, include, exclude, fail-fast ve max-parallel

Aynı job'ı farklı parametrelerle çalıştırmak için matrix kullanılır. Bookstore'da iki matrix var. Birincisi unit testleri iki işletim sisteminde koşturuyor:

  unit-test:
    needs: format
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet test --project api/tests/Bookstore.Api.Tests -c Release

İkincisi integration testleri üç PostgreSQL sürümüne karşı koşturuyor ve 18'i "deneysel" olarak işaretliyor:

  integration-test:
    needs: unit-test
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        postgres: ['16', '17']
        include:
          - postgres: '18'
            experimental: true
    continue-on-error: ${{ matrix.experimental == true }}

include iki iş yapar: var olan bir kombinasyona yeni bir key ekler ya da hiçbir kombinasyonla eşleşmiyorsa yeni bir kombinasyon oluşturur. Burada postgres: '18' mevcut değerlerle eşleşmediği için üçüncü bir job doğdu ve yalnız ona experimental: true eklendi. exclude ise tersini yapar, belirli kombinasyonları çıkarır; örneğin os × dotnet matrisinde Windows'ta eski sürümü atlamak için.

fail-fast varsayılan olarak true: matrix'teki bir job kırılınca GitHub aynı matrix'in koşan ve sıradaki diğer job'larını iptal eder. Hızlı geri bildirim için iyi, ama "Windows'ta mı yoksa her yerde mi kırık?" sorusunun cevabını da öldürür; biz bu yüzden false yaptık. max-parallel: 1 PostgreSQL job'larını sırayla koşturuyor. GitHub'da her job ayrı VM'de (Virtual Machine) olduğu için buna gerek yok; act'te ise üç job aynı Docker host'unda aynı 18232 port'unu isteyeceği için gerekiyordu. act bunu gerçekten uyguladı: log'da integration-test-1, -2 ve -3 birbiri ardına başladı.

Dokümantasyona göre bir matrix workflow run başına en fazla 256 job üretebilir. Asıl sınır ise bütçe: iki OS (Operating System, işletim sistemi) × üç .NET × üç veritabanı 18 job demek ve her biri ayrı ayrı dakika harcar.

act tarafında iki not. Birincisi, act yalnız Linux container'ları çalıştırabildiği için windows-latest kombinasyonunu atladı: Skipping unsupported platform -- Try running with `-P windows-latest=...`. İkincisi, aynı matrix'te desteklenmeyen bir platform olunca act 0.2.97'de image ataması karıştı: bir koşuda ubuntu-latest job'ı boş bir image adıyla (invalid reference format) düştü, başka bir denemede windows-latest kombinasyonu Ubuntu image'ında koştu. Bu yüzden act'i --matrix os:ubuntu-latest ile çalıştırıp matrix'i yalnız Linux kombinasyonuna daralttık. Windows tarafını bu yazıda doğrulamadık.

Service Container ile Integration Test: PostgreSQL

Integration testlerimiz gerçek bir PostgreSQL istiyor. Runner'a PostgreSQL kurmak yerine job'a bir service container ekliyoruz; GitHub bu container'ı job'dan önce başlatır, job bitince kaldırır:

    services:
      postgres:
        image: postgres:${{ matrix.postgres }}-alpine
        env:
          POSTGRES_DB: bookstore
          POSTGRES_USER: bookstore
          POSTGRES_HOST_AUTH_METHOD: trust
        ports:
          - 18232:5432
        options: >-
          --health-cmd "pg_isready -U bookstore -d bookstore"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      ConnectionStrings__Bookstore: Host=localhost;Port=18232;Database=bookstore;Username=bookstore
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - name: Integration tests (PostgreSQL ${{ matrix.postgres }})
        run: dotnet test --project api/tests/Bookstore.Api.IntegrationTests -c Release

Üç ayrıntı önemli. options altındaki health check olmadan runner, PostgreSQL bağlantı kabul etmeye başlamadan step'lere geçebilir ve ilk test "connection refused" ile düşer; health check varken runner container healthy olana kadar bekler. ports satırı, job runner'ın kendisinde koştuğu için gerekli: job ile service farklı yerlerde, aralarındaki köprü host'taki port. Job'ı container: ile bir container'da koştursaydın service'e postgres:5432 adıyla, port eşlemesi olmadan ulaşırdın. Son olarak, POSTGRES_HOST_AUTH_METHOD: trust şifresiz bağlantıya izin veriyor; container yalnız bu job süresince yaşadığı için CI'da kabul edilebilir, başka hiçbir yerde değil.

Host port'u olarak 5432 yerine 18232 yazdık, çünkü workflow'u act ile bu makinede koştururken başka işlerle çakışmayan bir port gerekiyordu; GitHub-hosted runner'da 5432:5432 da sorunsuz çalışır. Host port'unu hiç sabitlemek istemezsen - 5432/tcp yazıp atanan port'u job.services.postgres.ports['5432'] ile okuyabilirsin.

act log'unda service'in yaşam döngüsü açıkça görünüyor:

[ci/integration-test-1]   🐳  docker create image=postgres:16-alpine platform=linux/arm64 entrypoint=[] cmd=[] network="act-ci-integration-test-1-dc84ae659901e21be92d4b0742ae7d96d7079881b0ec547bf1201e01a03a9784-integra…
[ci/integration-test-1] container health of 358a678e7b9017776d6ff6f31cb6f9d5f168e6395ef7d50beb0a81a69b3501bf (postgres:16-alpine) is starting
[ci/integration-test-1] container health of 358a678e7b9017776d6ff6f31cb6f9d5f168e6395ef7d50beb0a81a69b3501bf (postgres:16-alpine) is healthy
[ci/integration-test-1]   ✅  Success - Set up job
[ci/integration-test-1] 🧪  Matrix: map[postgres:16]
...
[ci/integration-test-1] ⭐ Run Main Integration tests (PostgreSQL 16)
[ci/integration-test-1]   | Test run summary: Passed!
[ci/integration-test-1]   |   total: 3
[ci/integration-test-1]   |   failed: 0
[ci/integration-test-1]   |   succeeded: 3
[ci/integration-test-1]   ✅  Success - Main Integration tests (PostgreSQL 16) [7.244132334s]
...
[ci/integration-test-1] Cleaning up services for job integration-test
[ci/integration-test-1] 🏁  Job succeeded

Testler WebApplicationFactory ile API'yi (Application Programming Interface) bellekte ayağa kaldırıyor, EnsureCreated ile şemayı ve seed veriyi oluşturuyor ve gerçek SQL'e (Structured Query Language) karşı üç senaryo koşuyor. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısı bu test yaklaşımını ayrıntılı anlatıyor; burada yalnız CI tarafını kurduk.

Nisan 2026: Service Container'da entrypoint ve command

2 Nisan 2026 changelog'uyla service container'lara Docker Compose'daki gibi entrypoint ve command key'leri geldi. Örneğin PostgreSQL'i testler için daha hızlı, ama dayanıksız ayarlarla başlatmak:

    services:
      postgres:
        image: postgres:17-alpine
        command: postgres -c fsync=off -c synchronous_commit=off

Önceden bunun için özel bir image üretmek ya da options içinde dolambaçlı yollar denemek gerekiyordu. Ancak araçlar henüz yetişmemiş: command satırını eklediğimizde iki araç da itiraz etti.

$ actionlint
.github/workflows/ci.yml:55:9: unexpected key "command" for "services" section. expected one of "credentials", "env", "image", "options", "ports", "volumes" [syntax-check]
$ act -l
Error: workflow is not valid. 'ci.yml': Line: 40 Column 5: Failed to match job-factory: Line: 54 Column 9: Failed to match non-empty-string: Line: 54 Column 9: Expected a scalar got mapping
Line: 54 Column 9: Failed to match container-mapping: Line: 55 Column 9: Unknown Property command

actionlint 1.7.12, 30 Mart 2026'da, yani özellikten üç gün önce yayınlandı; act 0.2.97 de bu key'i tanımıyor. Bu yüzden yerelde koşturduğumuz workflow'da command yok. GitHub'da bu özelliği kullanacaksan şimdilik actionlint'te bu satır için uyarı göreceğini bil.

concurrency: Eski Run'ları İptal Etmek

Bir pull request'e beş dakika içinde üç commit push'larsan, varsayılan olarak üç tam CI run'ı koşar ve ilk ikisinin sonucu kimsenin umurunda değildir. concurrency bunu çözer:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Aynı group'taki yeni bir run başladığında cancel-in-progress doğruysa koşan eski run iptal edilir. Group adına workflow adını koymak şart; koymazsan aynı branch'teki başka bir workflow'un run'ını da iptal edersin. github.event.pull_request.number || github.ref ifadesi, pull request yoksa ref'e düşen bir fallback. cancel-in-progress'i yalnız pull request'lerde açtık: main'deki her commit'in CI sonucunu görmek istiyoruz, yarıda kesilmiş bir main build'i hangi commit'in kırdığını bulmayı zorlaştırır.

İki ayrıntı daha: group adları büyük/küçük harfe duyarsız ve varsayılan olarak bir group'ta en fazla bir run "pending" bekleyebilir, yenisi gelince bekleyen iptal edilir. Dokümantasyona yeni eklenen queue: max seçeneği 100'e kadar run'ın sırada beklemesine izin veriyor; deploy gibi "hiçbiri atlanmasın, ama aynı anda da koşmasın" işler için. queue: max ile cancel-in-progress: true birlikte kullanılamıyor. act tek seferde tek bir run koşturduğu için concurrency'nin etkisini yerelde gözlemleyemezsin; actionlint ise syntax'ı doğruluyor.

timeout-minutes ve continue-on-error

Her job'a timeout-minutes verdik. Sebep varsayılan değer: dokümantasyona göre bir job, timeout belirtilmezse 360 dakika, yani altı saat koşabilir. Watch modunda takılan bir test ya da cevap vermeyen bir servisi bekleyen bir step, private repository'de altı saatlik dakikayı sessizce yer. Kural basit: normal süresinin iki üç katı. Bookstore'da format için 10, unit test için 15, integration test için 20 dakika. Step seviyesinde de timeout-minutes var; tek bir yavaş adımı sınırlamak için.

continue-on-error iki seviyede var ve farklı şeyler yapıyor. Job seviyesinde, bizim PostgreSQL 18 örneğimizdeki gibi, o job başarısız olsa bile workflow run'ın başarısız sayılmamasını sağlar. Step seviyesinde ise step başarısız olsa da job'ın devam etmesini sağlar; üçüncü yazıda gördüğümüz outcome/conclusion farkı buradan gelir. İkisini de dikkatli kullan: "deneysel" etiketiyle eklenen ve aylarca kırmızı kalan bir job, kimsenin bakmadığı bir gürültüye dönüşür.

Üretimde Dikkat

Bu grafı tam olarak act'te koşturduğumuzda (Windows hariç) yerelde 11,5 dakika sürdü; makine o sırada başka işlerle de meşguldü, bu yüzden mutlak sayıdan çok oranlara bak. En uzun yol format → unit-test → integration-test × 3 → publish; web-build bu sürede paralel olarak bitti ve toplam süreye hiçbir şey eklemedi. GitHub'da da aynı mantık geçerli: toplam süre en uzun yolun (critical path) süresi, harcanan dakika ise bütün job'ların toplamı. Matrix'i büyütmek süreyi değil faturayı büyütür; zinciri uzatmak ise süreyi büyütür.

Ölçmek için her job'ın süresini birkaç hafta not et. Bir job sürekli critical path'teyse onu bölmeyi (unit ve integration'ı ayırmak gibi) ya da cache ile hızlandırmayı düşün; bir sonraki yazıda NuGet cache'ini ekleyeceğiz. Matrix'te her kombinasyonun gerçekten bir şey yakaladığını da sorgula: üç PostgreSQL sürümü yalnız production'da birden fazla sürüm varsa ya da yükseltmeye hazırlanıyorsan anlamlı.

Sık Düşülen Tuzaklar

Matrix kombinasyon patlaması. os: [ubuntu, windows, macos] × dotnet: [8, 9, 10] × db: [16, 17, 18] 27 job eder. macOS runner dakikaları Linux'tan pahalıdır. Her boyutu ayrı ayrı gerekçelendir, gerisini exclude ile at ya da yalnız gece koşan bir workflow'a taşı.

Varsayılan 360 dakikalık timeout. timeout-minutes yazmadığın her job, takıldığında altı saat bekler.

Health check'siz service. PostgreSQL'in "başladı" ile "bağlantı kabul ediyor" arasındaki birkaç saniye, aralıklı ve tekrarlanamayan test hatalarının en yaygın sebebi.

needs ile kırılan zincirde özet job'ın da atlanması. Bir rapor ya da bildirim job'ı her durumda koşmalıysa if: ${{ !cancelled() }} gerekir; aksi hâlde zincirdeki ilk hata onu da atlatır.

Concurrency group'unu workflow adı olmadan yazmak. group: ${{ github.ref }} aynı branch'teki bütün workflow'ları tek group'a toplar ve birbirlerini iptal etmelerine yol açar.

Küçük Bir Deneme

★☆☆ integration-test matrix'ine exclude ile PostgreSQL 16'yı çıkaran bir satır ekle ve act -l çıktısında job sayısının nasıl değiştiğine bak.

★★☆ unit-test'i bilerek kır, fail-fast'i önce true sonra false yaparak act ile koştur ve diğer matrix job'larının ne yaptığını karşılaştır.

★★★ publish'e bağlı bir report job'ı ekle: her durumda koşsun, needs.<job>.result değerlerini job summary'ye tablo olarak yazsın ve publish.outputs.version'ı da göstersin.

Bu grafın içindeki .NET job'larını şimdilik en sade hâlleriyle bıraktık: her job SDK'yı ve NuGet paketlerini sıfırdan indiriyor, warning'ler sessizce geçiyor, test raporu ve coverage yok. Bir sonraki yazıda api-ci.yml'ı kuracağız: global.json, lock dosyaları ve --locked-mode, NuGet cache, warning-as-error, Microsoft.Testing.Platform, TRX (Visual Studio Test Results) ve Cobertura raporları ve upload-artifact@v7.