.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?
| Konu | Ayrı workflow + paths | Tek workflow + job koşulları |
|---|---|---|
| Değişiklik tespiti | GitHub yapar, workflow hiç başlamaz | Sen yaparsın (changes job'ı) |
| Atlanan iş | Workflow "yok" sayılır, check pending kalır | Job "skipped" olur, run tamamlanır |
| Required check | Path filtresiyle uyumsuz | Tek bir özet job required yapılır |
| Okunabilirlik | Her uygulamanın CI'ı kendi dosyasında | Tek dosya, daha uzun |
| Job'lar arası bağ | Workflow'lar arasında needs yok | needs ve artifact ile doğal |
| Maliyet | Atlanan workflow hiç runner açmaz | changes 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ığı.