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

.NET + Angular Monorepo'da GitHub Actions: Path Filtreleri, Artifact Aktarımı ve Tek Release Paketi

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
.NET + Angular Monorepo'da GitHub Actions: Path Filtreleri, Artifact Aktarımı ve Tek Release Paketi

Path filtreli ayrı workflow'lar required check'i pending bırakır; monorepo'da tek workflow, git diff ile karar veren bir changes job'ı ve her zaman koşan bir özet job (ci-ok) bu sorunu çözer. Angular dist'ini artifact ile .NET publish'in wwwroot'una taşıyıp tag push'unda tek bir release zip'i üretiyor, artifact ile cache farkını ve gh release create ile GitHub Release'i act çıktılarıyla anlatıyoruz.

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), Node 24, Angular 22, actions/upload-artifact@v7 ve actions/download-artifact@v8 ile denenmiştir. Workflow'lar act ile yerelde koşuldu; GitHub'da koşulmadı ve GitHub Release oluşturan job hiç çalıştırılmadı.

Önce Tahmin Et

Bookstore repository'sinde iki CI (Continuous Integration) workflow'u var: api-ci.yml yalnız api/** değişince, web-ci.yml yalnız web/** değişince koşuyor. Ekip main'i korumak için iki workflow'un da önemli job'larını required check yapıyor: build-test ve lint-test-build. Ada yalnız Angular tarafında bir CSS (Cascading Style Sheets) düzeltmesi yapıp pull request açıyor. web-ci koşup yeşil bitiyor. Pull request merge edilebilir mi?

Edilemez. api-ci path filtresi yüzünden hiç tetiklenmedi ve dokümantasyonun açıkça yazdığı gibi, filtre yüzünden atlanan bir workflow'un check'i "Pending" durumda kalır; required check pending olduğu sürece merge düğmesi kilitli. Bu, monorepo'da path filtresi kullanan neredeyse her ekibin bir noktada yaşadığı sorun. Bu yazıda çözümünü ve monorepo'nun diğer iki sorusunu ele alıyoruz: Angular'ın build çıktısını .NET publish'ine nasıl taşırız ve tag push'unda tek bir release paketini nasıl üretiriz.

İki Yol: Ayrı Workflow'lar mı, Tek Workflow mu?

KonuAyrı workflow + pathsTek workflow + job koşulları
Değişiklik tespitiGitHub yapar, workflow hiç başlamazSen yaparsın (changes job'ı)
Atlanan işWorkflow "yok" sayılır, check pending kalırJob "skipped" olur, run tamamlanır
Required checkPath filtresiyle uyumsuzTek bir özet job required yapılır
OkunabilirlikHer uygulamanın CI'ı kendi dosyasındaTek dosya, daha uzun
Job'lar arası bağWorkflow'lar arasında needs yokneeds ve artifact ile doğal
MaliyetAtlanan workflow hiç runner açmazchanges ve özet job her seferinde birkaç saniye runner açar

Required check kullanmıyorsan ayrı workflow'lar sade ve yeterli. Kullanıyorsan iki seçenek var: ya path filtreli workflow'ların job'larını required check listesinden çıkarırsın (ve kırmızı bir check'in merge'ü engellememesini kabul edersin), ya da tek workflow'a geçip "her zaman koşan özet job"u required yaparsın. Bookstore için ikincisini ci.yml olarak kurduk. Seride api-ci.yml ve web-ci.yml'ı da ayrıntılı örnek olarak repository'de tutuyoruz; gerçek bir projede birini seçersin, ikisini birden değil.

changes Job'ı: Hangi Klasör Değişti?

Tek workflow her push'ta ve pull request'te tetikleniyor; hangi job'ların koşacağına ilk job karar veriyor:

  changes:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - id: filter
        env:
          BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
        run: |
          if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
            echo "no base commit, building everything"
            echo "api=true" >> "$GITHUB_OUTPUT"
            echo "web=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          changed=$(git diff --name-only "$BASE" "$GITHUB_SHA")
          echo "$changed"
          api=false
          web=false
          if grep -qE '^(api/|global\.json$|\.github/workflows/ci\.yml$)' <<< "$changed"; then api=true; fi
          if grep -qE '^(web/|\.github/workflows/ci\.yml$)' <<< "$changed"; then web=true; fi
          echo "api=$api" | tee -a "$GITHUB_OUTPUT"
          echo "web=$web" | tee -a "$GITHUB_OUTPUT"

Karşılaştırma tabanı event'e göre değişiyor: pull request'te github.event.pull_request.base.sha, push'ta github.event.before (push'tan önce branch'in gösterdiği commit). Yeni bir branch'in ilk push'unda before sıfırlardan oluşur; o durumda bir şey kaçırmamak için her şeyi build ediyoruz. fetch-depth: 0 gerekli, çünkü checkout varsayılan olarak yalnız son commit'i çeker ve git diff taban commit'i bulamaz. BASE'i env: üzerinden vermemizin sebebi de ikinci yazıdaki script injection kuralı.

Bu işi yapan hazır action'lar da var (en bilineni dorny/paths-filter); YAML'da (YAML Ain't Markup Language) glob'larla filtre yazmanı sağlıyor ve pull request'te API'den (Application Programming Interface) değişen dosya listesini alıyor. Biz birkaç satırlık bir shell script'i tercih ettik: üçüncü taraf bir bağımlılık eklemiyor ve ne yaptığı ilk bakışta görülüyor. Hangi yolu seçersen seç, ilk yazıdaki Marketplace değerlendirmesini burada da yap.

Ada'nın yalnız web/src/styles.css'i değiştirdiği push'u act'e, before alanı dolu bir event payload'ıyla verdik:

$ act push -W .github/workflows/ci.yml -e push-web-only.json
[ci/changes]   | web/src/styles.css
[ci/changes]   | api=false
[ci/changes]   | web=true
[ci/changes] 🏁  Job succeeded
[ci/web    ] 🏁  Job succeeded
[ci/ci-ok  ] ⭐ Run Main Check job results
[ci/ci-ok  ]   | results: success skipped success
[ci/ci-ok  ]   | all required jobs passed or were skipped
[ci/ci-ok  ] 🏁  Job succeeded

git diff tek bir dosya buldu, api job'ı koşmadı, web lint, test ve build'i geçti ve ci-ok üç sonucu (changes, api, web sırasıyla) okuyup yeşil bitti. Bu, act'in path filtrelerini değerlendirmediği ikinci yazıdaki durumun tersine, tamamen act'te doğrulanabilen bir mekanizma: karar workflow'un içinde, bir shell script'inde veriliyor.

Her Zaman Koşan Özet Job: ci-ok

Required check olarak seçilecek job'ın iki özelliği olmalı: her run'da mutlaka koşmalı ve yalnız gerçekten bir şey kırıldığında kırmızı olmalı. "Atlandı" kırmızı değildir:

  ci-ok:
    needs: [changes, api, web]
    if: ${{ always() }}
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check job results
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          echo "results: $RESULTS"
          for r in $RESULTS; do
            if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
              echo "::error::a required job finished with '$r'"
              exit 1
            fi
          done
          echo "all required jobs passed or were skipped"

if: ${{ always() }} olmadan bu job, bağlı olduğu job'lardan biri atlandığında kendisi de atlanırdı ve dördüncü yazıda gördüğümüz zincir kuralı burada da işlerdi. needs.*.result object filter syntax'ıyla bütün bağımlı job'ların sonuçlarını bir array olarak verir; join onu boşlukla ayrılmış bir string'e çevirir. always()'ı burada bilerek kullandık: özet job'ın iptal edilen bir run'da da "cancelled" sonucunu raporlayıp kırmızı olması isteniyor. Branch ruleset'inde required check olarak yalnız ci-ok seçilir; api ve web'in adları ileride değişse bile kural bozulmaz.

Yukarıdaki act çıktısındaki results: success skipped success satırı bu job'ın gördüğü şey. api kırılsaydı satır failure içerir, döngü ::error:: annotation'ı basıp 1 ile çıkardı.

Artifact ile Job'lar Arası Taşıma: Angular dist'inden wwwroot'a

Bookstore'u tek bir paket olarak yayınlamak istiyoruz: API kendi wwwroot klasöründen Angular uygulamasını servis etsin. Bunun için Program.cs'e üç satır ekledik:

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapHealthChecks("/health");
// ...
app.MapFallbackToFile("index.html");

MapFallbackToFile, hiçbir endpoint'le eşleşmeyen istekleri index.html'e yönlendiriyor; Angular routing'in sayfa yenilendiğinde 404 vermemesi için. Angular serisinin "Angular Build ve Deploy" yazısındaki SPA (Single Page Application) routing konusunun .NET tarafındaki karşılığı bu. Release workflow'u Angular'ı bir job'da build edip artifact olarak yüklüyor, .NET job'ı da onu indirip publish'ten önce wwwroot'a koyuyor:

  web:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    defaults:
      run:
        working-directory: web
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version-file: web/package.json
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: web-dist
          path: web/dist/bookstore-web/browser/
          retention-days: 1

  package:
    needs: web
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: api/**/packages.lock.json
      - id: version
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
      - uses: actions/download-artifact@v8
        with:
          name: web-dist
          path: api/src/Bookstore.Api/wwwroot
      - run: dotnet restore api/src/Bookstore.Api --locked-mode
      - name: Publish API with the Angular app in wwwroot
        run: >
          dotnet publish api/src/Bookstore.Api -c Release --no-restore -o out/bookstore
          -p:Version=${{ steps.version.outputs.version }}
      - name: Zip
        run: |
          cd out
          zip -qr "bookstore-${{ steps.version.outputs.version }}.zip" bookstore
          ls -l "bookstore-${{ steps.version.outputs.version }}.zip"
          unzip -l "bookstore-${{ steps.version.outputs.version }}.zip" | grep -E 'wwwroot/(index\.html|main-)|Bookstore\.Api\.dll'
      - uses: actions/upload-artifact@v7
        with:
          name: release-package
          path: out/bookstore-${{ steps.version.outputs.version }}.zip
          retention-days: 7

Tag v1.4.0 olduğunda ${GITHUB_REF_NAME#v} baştaki v'yi atıp 1.4.0 bırakıyor; bu değer hem assembly sürümüne hem dosya adına gidiyor. web-dist'in retention-days değeri 1, çünkü yalnız aynı run'daki bir sonraki job için var; asıl ürün release-package.

$ act push -W .github/workflows/release.yml -e push-tag-v140.json -j package
[release/web]   | Artifact web-dist has been successfully uploaded! Final size is 52975 bytes. Artifact ID is 1094708388
[release/web] 🏁  Job succeeded
[release/package]   ⚙  ::set-output:: version=1.4.0
[release/package] ⭐ Run Main actions/download-artifact@v4
[release/package]   | Starting download of artifact to: …/api/src/Bookstore.Api/wwwroot
[release/package]   | Artifact download completed successfully.
[release/package] ⭐ Run Main Publish API with the Angular app in wwwroot
[release/package]   |   Bookstore.Api -> …/out/bookstore/
[release/package] ⭐ Run Main Zip
[release/package]   | -rw-r--r-- 1 root root 2630285 Nov  8 04:28 bookstore-1.4.0.zip
[release/package]   |    143938  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js
[release/package]   |       161  2026-11-08 04:28   bookstore/wwwroot/index.html.br
[release/package]   |     42898  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js.br
[release/package]   |       292  2026-11-08 04:28   bookstore/wwwroot/index.html.gz
[release/package]   |       426  2026-11-08 04:28   bookstore/wwwroot/index.html
[release/package]   |     48539  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js.gz
[release/package]   |     24064  2026-11-08 04:28   bookstore/Bookstore.Api.dll
[release/package] 🏁  Job succeeded

act'te -j package verince bağımlı olduğu web job'ı da koştu; event payload'ındaki refs/tags/v1.4.0 sayesinde GITHUB_REF_NAME v1.4.0 oldu ve sürüm 1.4.0 olarak çıktı. 2,6 MB'lık zip'in içinde API'nin DLL'i (Dynamic Link Library) ve wwwroot altında Angular dosyaları var. Bir sürpriz: .br ve .gz dosyalarını biz üretmedik; dotnet publish, wwwroot'taki statik dosyaların Brotli ve gzip ile önceden sıkıştırılmış kopyalarını kendisi oluşturdu. Artifact adımlarını yine act'in desteklediği @v4 sürümleriyle koşturduk; repository'deki workflow upload-artifact@v7 ve download-artifact@v8 kullanıyor.

Artifact mı, Cache mi?

İkisi de dosyaları bir job'dan dışarı çıkarıyor ve sık karıştırılıyor. Fark amaçta. Artifact bir run'ın çıktısıdır: aynı run'daki başka bir job'a aktarılır, run sayfasından indirilebilir, retention süresi boyunca saklanır ve "bu build'in ürünü budur" der. Cache ise run'lar arası bir hızlandırıcıdır: bir key'le kaydedilir, sonraki run'lar aynı ya da benzer key'le geri yükler, yer dolunca ya da uzun süre kullanılmayınca silinebilir ve hiçbir zaman garanti değildir. Cache bulunamadığında build yavaşlar ama doğru çalışmaya devam etmeli; artifact bulunamadığında ise bağlı job'ın yapacak bir şeyi kalmaz.

Pratik kural: NuGet paketleri, npm cache'i ve Playwright tarayıcıları cache'e; test raporları, publish çıktısı, Angular dist'i ve release paketi artifact'a. Angular dist'ini cache ile taşımaya kalkarsan, key'i yanlış kurduğun bir gün bir önceki commit'in dist'iyle paket üretirsin ve bunu fark etmezsin.

Tag Push'unda GitHub Release

Son job, paketi bir GitHub Release'e asset olarak ekliyor. Git & GitHub serisinin "Git Tag ve Release: Sürümleri İşaretlemek, git describe ve GitHub Releases" yazısında release'i elle oluşturmuştuk; burada aynı işi tag push'u tetikliyor:

  github-release:
    needs: package
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: release-package
      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: >
          gh release create "$GITHUB_REF_NAME"
          "bookstore-${{ needs.package.outputs.version }}.zip"
          --title "Bookstore $GITHUB_REF_NAME" --generate-notes --verify-tag

Workflow seviyesinde permissions: contents: read var; yalnız bu job contents: write alıyor, çünkü release oluşturmak repository'ye yazmak demek. Hazır bir release action'ı yerine runner'larda kurulu gelen GitHub CLI'ı (gh, Command Line Interface) kullandık; --generate-notes önceki tag'den bu yana birleşen pull request'lerden release notu üretiyor, --verify-tag tag'in remote'ta gerçekten var olduğunu kontrol ediyor. Bu job'ı act'te çalıştırmadık: gerçek bir repository ve token gerektiriyor ve bu seride GitHub'a hiçbir şey göndermiyoruz. actionlint ile doğruladık; GITHUB_TOKEN ve permission'ların ayrıntısı bir sonraki yazının konusu.

Üretimde Dikkat

Monorepo CI'ında asıl kazanç atlanan işlerden geliyor. Bookstore'da bir hafta boyunca pull request'lerin kaçının yalnız web/'e, kaçının yalnız api/'ye dokunduğunu say; oran yüksekse changes job'ının birkaç saniyelik maliyeti kat kat geri döner. Düşükse, yani neredeyse her değişiklik iki tarafa da dokunuyorsa, bu karmaşıklık sana bir şey kazandırmıyor demektir.

Artifact boyutlarını da izle. Bizim api-publish artifact'ımız 2,5 MB, Angular dist'i birkaç yüz kB. Ama node_modules'u ya da bin/obj klasörlerini yanlışlıkla artifact'a koymak yüzlerce MB demek: yükleme ve indirme süresi, depolama kotası ve her run'da tekrar. path'i olabildiğince dar yaz ve if-no-files-found: error ile yanlış yolun sessizce boş artifact üretmesini engelle.

Sık Düşülen Tuzaklar

Path filtreli workflow'u required check yapmak. Tahmin bölümündeki kilitli merge düğmesi. Özet job ya da tek workflow kullan.

fetch-depth'i unutmak. Varsayılan sığ checkout'ta git diff "$BASE" "bad object" hatası verir ya da yanlış sonuç üretir.

Özet job'da always()'ı unutmak. Bağlı bir job atlandığında özet job da atlanır ve required check yine pending kalır.

Artifact'ları job'lar arasında gereksiz büyütmek. Bütün workspace'i artifact yapıp sonraki job'da açmak, çoğu zaman o job'da checkout edip yeniden build etmekten yavaştır.

Release workflow'unda testleri atlamak. Tag push'u test edilmemiş bir commit'e konmuş olabilir. Ya tag'i yalnız CI'ı yeşil commit'lere koyan bir süreç kur, ya da release workflow'unda testleri tekrar koştur.

Küçük Bir Deneme

★☆☆ changes job'ına bir docs çıktısı ekle (docs/ klasörü için) ve yalnız doküman değişikliklerinde koşan bir markdown lint job'ı tanımla.

★★☆ act ile api job'ını bilerek kır (bir testi bozarak) ve ci-ok'un ne raporladığına bak. Sonra ci-ok'taki always()'ı !cancelled() yap ve farkın ne zaman ortaya çıkacağını düşün.

★★★ Release paketine bir SHA256SUMS dosyası ekle (sha256sum ile) ve github-release job'ında iki dosyayı birlikte yükle. Paketi indirip doğrulayan bir shell komutunu release notuna yaz.

Bu yazıda GITHUB_TOKEN'ı bir job'da yazma yetkisiyle kullandık ama ne olduğunu, ne kadar yaşadığını ve neden permissions: ile daraltılması gerektiğini konuşmadık. Bir sonraki yazıda secret'lar, variable'lar ve GITHUB_TOKEN permission'ları var: hangi seviyede tanımlanırlar, log'da nasıl maskelenirler ve maskelemenin nerede yetersiz kaldığı.