GitHub Actions ile .NET CI: setup-dotnet, global.json, NuGet Cache, Test Raporu ve Coverage
.NET 10 CI'ında SDK'yı global.json ve setup-dotnet@v6 ile sabitle, restore'u lock dosyaları ve --locked-mode ile yap, NuGet cache'ini lock hash'ine bağla, dotnet format ve -warnaserror ile kapı koy. xUnit v3'ün Microsoft.Testing.Platform modunda TRX ve Cobertura raporu üretip job summary'ye yazıyor, upload-artifact ile saklıyoruz; act'in v7 artifact sınırını ve Linux'taki dosya adı tuzağını da gerçek çıktılarla gösteriyoruz.
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), xUnit v3 4.0, Microsoft.Testing.Extensions.CodeCoverage 18.12, actions/setup-dotnet@v6 ve actions/upload-artifact@v7 ile denenmiştir. Workflow'lar act ile yerelde koşuldu; GitHub'da koşulmadı.
Önce Tahmin Et
Alan, eski bir projeden kopyaladığı .NET CI'ını (Continuous Integration) Bookstore'a uyarlıyor. Testler xUnit v3'ün güncel sürümüyle yazılmış, workflow'daki komut da yıllardır kullandığı satır:
dotnet test api/tests/Bookstore.Api.Tests
Kendi makinesinde .NET 10 SDK'sı (Software Development Kit) kurulu ve proje net10.0 hedefliyor. Repository'deki global.json'da yalnız SDK sürümü var. Bu komut ne yapar: testleri koşar mı, uyarı mı verir, yoksa hiç başlamaz mı?
$ dotnet test api/tests/Bookstore.Api.Tests
~/.nuget/packages/microsoft.testing.platform.msbuild/2.5.1/buildMultiTargeting/Microsoft.Testing.Platform.MSBuild.targets(395,5): error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
Hiç başlamaz. xUnit v3 4.x test projeleri Microsoft.Testing.Platform (MTP) üzerinde koşuyor ve .NET 10 SDK'sında MTP projelerini eski VSTest yoluyla çalıştırmak artık desteklenmiyor. Çözüm tek satır, ama hangi satır olduğunu bilmek gerekiyor; bu yazıda ona ve .NET CI'ın geri kalan parçalarına bakıyoruz: SDK'yı sabitlemek, lock dosyaları, NuGet cache, kalite kapıları, test raporu, coverage ve artifact'lar.
SDK'yı Sabitlemek: global.json ve setup-dotnet@v6
İlk yazıdan beri kullandığımız global.json'un son hâli:
{
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
sdk bölümü iki şey söylüyor: en az 10.0.400 ve aynı major/minor içinde bulunan en yeni feature band. actions/setup-dotnet@v6 bu dosyayı global-json-file input'uyla okuyor; README'si latestPatch, latestFeature, latestMinor ve latestMajor için en yüksek uygun SDK'yı kurduğunu, rollForward kullanılıyorsa version'ın tam bir SDK sürümü olması gerektiğini (10.0.* gibi wildcard olmaz) yazıyor. act'teki koşularda bu kural 10.0.403'ü seçti; bu makinede ise 10.0.402 kurulu ve o da kurala uyuyor. Fark küçük, ama varlığını bilmek önemli: CI her zaman en yeni patch'i alır, geliştirici makinesi bir adım geride olabilir.
test bölümü tahmin bölümündeki hatanın çözümü. .NET 10 ile dotnet test iki moda ayrıldı: VSTest ve Microsoft.Testing.Platform. MTP modunda dotnet test test projelerini doğrudan executable olarak çalıştırıyor; bu yüzden test projelerimizde <OutputType>Exe</OutputType> var ve Microsoft.NET.Test.Sdk ile xunit.runner.visualstudio paketlerine gerek kalmadı. Komut satırı da biraz değişti: proje ya da solution --project ve --solution ile veriliyor, test framework'üne giden ek argümanlar (rapor, coverage) aynı satıra yazılıyor.
Lock Dosyaları, --locked-mode ve NuGet Cache
Bookstore'un api/Directory.Build.props dosyasında <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> var; her proje için bir packages.lock.json üretiliyor ve repository'ye giriyor. Lock dosyası transitive bağımlılıklar dahil her paketin tam sürümünü ve hash'ini tutar. CI'da restore'u --locked-mode ile yapıyoruz: lock dosyası ile proje dosyaları uyuşmuyorsa restore durur. Birisi csproj'da paket sürümünü değiştirip lock dosyasını güncellemeyi unuttuğunda:
$ dotnet restore api/Bookstore.slnx --locked-mode
…/api/src/Bookstore.Api/Bookstore.Api.csproj : error NU1004: The package reference Npgsql.EntityFrameworkCore.PostgreSQL version has changed from [10.0.3, ) to [10.0.2, ).The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. Disable the RestoreLockedMode MSBuild property or pass an explicit `--force-evaluate` option to run restore to update the lock file.
Bu hata iyi bir şey: CI, geliştiricinin makinesinde çözülenden farklı bir paket ağacıyla derleme yapamıyor. Web Güvenliği (OWASP Top 10) serisinin OWASP A03 (Software Supply Chain Failures) yazısında lock dosyalarının supply chain tarafını konuşmuştuk; burada tekrarlanabilir build tarafını görüyoruz.
Lock dosyalarının bir yan faydası da cache key'i olmaları. setup-dotnet'in cache input'u NuGet global packages klasörünü cache'liyor ve key'i lock dosyalarının hash'inden üretiyor; README'ye göre lock dosyası bulamazsa hata veriyor:
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
cache: true
cache-dependency-path: api/**/packages.lock.json
- run: dotnet restore api/Bookstore.slnx --locked-mode
act'in yerel cache server'ıyla ilk job'da cache yoktu ve job sonunda kaydedildi; sonraki job'lar aynı key'le geri yükledi:
[api-ci/format] | Dotnet cache is not found
[api-ci/format] ⚙ ::set-output:: cache-hit=false
[api-ci/format] ✅ Success - Main dotnet restore api/Bookstore.slnx --locked-mode [5.13875925s]
[api-ci/format] | Cache Size: ~72 MB (75874143 B)
[api-ci/format] | Cache saved with the key: dotnet-cache-Linux-ef61870070da36c522f7ec79330dcf2777402de8d84627544eeb2d86afdb55b8
...
[api-ci/build-test ] | Cache restored from key: dotnet-cache-linux-ef61870070da36c522f7ec79330dcf2777402de8d84627544eeb2d86afdb55b8
[api-ci/build-test ] ⚙ ::set-output:: cache-hit=true
[api-ci/build-test ] ✅ Success - Main Restore [2.025406375s]
Soğuk restore 5,1 saniye, cache'ten gelen restore 2 saniye sürdü. Bu küçük projede fark birkaç saniye; yüzlerce paketli bir solution'da dakikalar. Key'in sonundaki hash, lock dosyaları değişmedikçe aynı kalır; bir paket güncellendiğinde yeni bir key üretilir ve ilk job cache'i yeniden doldurur. Cache'in nasıl çalıştığını, eviction'ı ve repository cache limitini hata ayıklama ve maliyet yazısında ayrıca ele alacağız.
Kalite Kapıları: dotnet format ve Warning-as-Error
api-ci.yml'ın ilk job'ı format: restore'dan sonra dotnet format --verify-no-changes çalıştırıyor. Bu komut kodu değiştirmez; .editorconfig kurallarına göre değişmesi gereken bir şey varsa hata verip sıfırdan farklı bir exit code döner. Bir satırın girintisini iki boşluk eksik bıraktığımız branch'te act çıktısı:
[api-ci/format] ⭐ Run Main dotnet format api/Bookstore.slnx --verify-no-changes --no-restore
[api-ci/format] | …/api/src/Bookstore.Api/OrderPricing.cs(16,7): error WHITESPACE: Fix whitespace formatting. Insert '\s\s'. […/api/src/Bookstore.Api/Bookstore.Api.csproj]
[api-ci/format] ❌ Failure - Main dotnet format api/Bookstore.slnx --verify-no-changes --no-restore [3.2338215s]
[api-ci/format] exitcode '2': failure
[api-ci/format] 🏁 Job failed
Mesaj hangi dosyanın hangi satırında neyin eksik olduğunu söylüyor; düzeltmesi yerelde dotnet format api/Bookstore.slnx çalıştırmak. format job'ı kırıldığında diğer bütün job'lar needs yüzünden atlanıyor; bu kontrolü öne koymamızın sebebi buydu. Bir not: bu makinede dotnet format'un mesajları işletim sisteminin diline göre Türkçe çıktı; CI'daki İngilizce çıktı runner'ın locale'inden geliyor.
Build adımında ise -warnaserror kullanıyoruz. Yerelde warning'ler kimseyi durdurmaz ve zamanla yüzlerce birikir; CI'da her warning'i hata saymak bu birikmeyi baştan keser. Kullanılmayan bir değişken ekleyince:
[api-ci/format] 🏁 Job succeeded
[api-ci/build-test] ⭐ Run Main Build
[api-ci/build-test] | …/api/src/Bookstore.Api/OrderPricing.cs(23,13): error CS0219: The variable 'unused' is assigned but its value is never used […/api/src/Bookstore.Api/Bookstore.Api.csproj]
[api-ci/build-test] | 1 Error(s)
[api-ci/build-test] ❌ Failure - Main Build [1.863064s]
[api-ci/build-test] 🏁 Job failed
Dikkat: format job'ı bu değişikliği geçirdi, çünkü kullanılmayan bir değişken bir biçim sorunu değil, derleyici uyarısı (CS0219). İki kapı farklı şeyleri yakalıyor; biri diğerinin yerine geçmiyor.
Bunu Directory.Build.props'a TreatWarningsAsErrors olarak yazmak yerine komut satırında vermemizin sebebi yerel geliştirme deneyimi: kod yazarken yarım kalmış bir değişken build'i kırmasın, ama main'e giden her şey sıfır warning'le gitsin. İki yaklaşım da yaygın; önemli olan kuralın CI'da uygulanması.
Test Raporu ve Coverage: TRX, Cobertura ve Job Summary
Unit test step'i MTP'ye iki rapor ürettiriyor: xUnit v3'ün TRX (Visual Studio Test Results) raporu ve Microsoft'un code coverage extension'ının Cobertura formatındaki coverage raporu. Coverage için test projelerine Microsoft.Testing.Extensions.CodeCoverage paketini ekledik:
- name: Unit tests
run: >
dotnet test --project api/tests/Bookstore.Api.Tests --no-build -c Release
--results-directory TestResults --report-xunit-trx
--coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml
- name: Test summary
if: ${{ !cancelled() }}
run: python3 .github/scripts/test-summary.py TestResults | tee -a "$GITHUB_STEP_SUMMARY"
test-summary.py, expression yazısındaki TRX script'inin coverage satırı eklenmiş hâli:
# TRX test raporlarından ve bir Cobertura coverage dosyasından Markdown job summary yazar
import glob
import sys
import xml.etree.ElementTree as ET
results = sys.argv[1]
ns = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
print("| Tests | Total | Passed | Failed | Skipped |")
print("|---|---:|---:|---:|---:|")
failures = []
for path in sorted(glob.glob(f"{results}/**/*.trx", recursive=True)):
root = ET.parse(path).getroot()
c = root.find("t:ResultSummary/t:Counters", ns).attrib
name = root.find("t:TestDefinitions/t:UnitTest", ns).attrib["storage"].split("/")[-1]
skipped = int(c["total"]) - int(c["executed"])
print(f"| {name} | {c['total']} | {c['passed']} | {c['failed']} | {skipped} |")
failures += [r.attrib["testName"] for r in root.findall("t:Results/t:UnitTestResult[@outcome='Failed']", ns)]
for name in failures:
print(f"\n:x: `{name}`")
for path in sorted(glob.glob(f"{results}/**/*.cobertura.xml", recursive=True)):
cov = ET.parse(path).getroot().attrib
print(f"\nLine coverage: **{float(cov['line-rate']):.1%}** ({cov['lines-covered']}/{cov['lines-valid']}), "
f"branch coverage: **{float(cov['branch-rate']):.1%}**")
[api-ci/build-test ] ⭐ Run Main Test summary
[api-ci/build-test ] | | Tests | Total | Passed | Failed | Skipped |
[api-ci/build-test ] | |---|---:|---:|---:|---:|
[api-ci/build-test ] | | Bookstore.Api.Tests.dll | 6 | 6 | 0 | 0 |
[api-ci/build-test ] |
[api-ci/build-test ] | Line coverage: **18.0%** (11/61), branch coverage: **30.8%**
Yüzde 18 düşük görünüyor, ama doğru okumak gerekiyor: unit testler yalnız OrderPricing'i test ediyor; Program.cs'teki endpoint'ler ve DbContext integration testlerin alanı. Coverage'ı bir eşik olarak değil bir harita olarak kullan: "hangi kritik kod hiç test edilmiyor?" sorusuna cevap verir, "testler yeterli mi?" sorusuna vermez. Cobertura formatını seçmemizin sebebi neredeyse her raporlama aracının onu okuyabilmesi.
upload-artifact@v7 ile Test Sonuçları ve Publish Çıktısı
Job summary özet içindir; tam TRX ve coverage dosyalarını indirilebilir tutmak için artifact kullanıyoruz. publish job'ı da dotnet publish çıktısını artifact olarak yüklüyor; deploy yazılarında bu artifact'ı alıp kullanacağız:
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: test-results
path: TestResults/
retention-days: 14
publish:
needs: [build-test, integration-test]
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 10
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
- run: dotnet restore api/src/Bookstore.Api --locked-mode
- run: dotnet publish api/src/Bookstore.Api -c Release --no-restore -o publish
- uses: actions/upload-artifact@v7
with:
name: api-publish
path: publish/
retention-days: 7
act ile koşturduğumuzda bu step'te bir engelle karşılaştık: act 0.2.97'nin yerel artifact server'ı upload-artifact@v7 ile konuşamadı. Aynı step beş deneme sonunda düştü:
[api-ci/build-test ] ⭐ Run Main actions/upload-artifact@v7
[api-ci/build-test ] | With the provided path, there will be 2 files uploaded
[api-ci/build-test ] | Artifact name is valid!
[api-ci/build-test ] | Root directory input is valid!
[api-ci/build-test ] | Attempt 1 of 5 failed with error: Unexpected end of JSON input. Retrying request in 8000 ms...
...
[api-ci/build-test ] ❌ Failure - Main actions/upload-artifact@v7 [1m29.955726834s]
Ayrı bir küçük workflow'la doğruladık: aynı act sürümünde upload-artifact@v4 sorunsuz çalışıyor, @v7 çalışmıyor. Bu yüzden yalnız yerel koşu için workflow'un bir kopyasında @v7'yi @v4 ile değiştirdik; yazıdaki ve repository'deki workflow @v7 kullanıyor ve GitHub'da @v7 ile koşturulmadı. @v4 ile act çıktısı:
[api-ci/build-test ] | Artifact test-results has been successfully uploaded! Final size is 3568 bytes. Artifact ID is 3486992502
...
[api-ci/publish ] ✅ Success - Main dotnet publish api/src/Bookstore.Api -c Release --no-restore -o publish [1.566237917s]
[api-ci/publish ] | Artifact api-publish has been successfully uploaded! Final size is 2513485 bytes. Artifact ID is 533688365
retention-days artifact'ın ne kadar saklanacağını belirler; repository ayarındaki üst sınırı aşamaz. Test sonuçlarını iki hafta, publish çıktısını bir hafta tuttuk; gerçek release'ler artifact'ta değil, GitHub Release'te ya da bir registry'de yaşamalı. publish job'ındaki if: ${{ github.event_name == 'push' }} pull request'lerde publish adımını atlıyor; artifact depolaması da kotadan düşer.
Üretimde Dikkat
Bu workflow'un act'teki toplam süresinin büyük kısmı SDK kurulumu ve restore'dan geliyor: her job SDK'yı yeniden indiriyor, çünkü her job ayrı bir makine. GitHub-hosted runner'larda bazı SDK sürümleri image'da hazır gelir; check-latest: false ile setup-dotnet'e "uyan bir SDK kuruluysa onu kullan" diyebilirsin, karşılığında en yeni patch'i garanti etmekten vazgeçersin. NuGet cache'in etkisi de proje büyüdükçe artar; 72 MB'lık bizim cache'imizde restore act'te 5,1 saniyeden 2 saniyeye indi.
Integration testler için iki yol var. Bu seride service container kullandık: veritabanını workflow ayağa kaldırıyor, test kodu yalnız connection string'i biliyor. Diğer yol Testcontainers: test kodu Docker'a bağlanıp kendi container'ını başlatıyor; aynı test yerelde ve CI'da hiçbir ek kurulum olmadan koşuyor. Bedeli runner'da Docker gerekmesi ve test başlangıcının biraz uzaması. Entity Framework Core (EF Core) serisinin "EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları" yazısı ve ASP.NET Core Web API (Application Programming Interface) serisinin integration test yazısı bu seçeneklere değiniyor; GitHub'ın Linux runner'larında Docker hazır olduğu için iki yol da çalışır. Bu yazıda Testcontainers'lı bir sürümü koşturmadık.
Son olarak coverage'ı bir kapıya dönüştürmeden önce düşün. "Coverage %80'in altına düşerse build kırılsın" kuralı, anlamsız testler yazılmasına yol açabilir. Daha işe yarayan kural genellikle "yeni kodun coverage'ı düşmesin" ve bunu pull request yorumuyla göstermek.
Sık Düşülen Tuzaklar
Linux'ta dosya adı büyük/küçük harf farkı. Bir test, TestData/Books.json dosyasını books.json diye okuyordu. macOS'ta (varsayılan olarak harf duyarsız dosya sistemi) testler geçti, act'in Linux container'ında ise kırıldı:
[api-ci/build-test] ⭐ Run Main Unit tests
[api-ci/build-test] | failed Bookstore.Api.Tests.SampleOrderTests.Sample_order_from_file_ships_free (7ms)
[api-ci/build-test] | Xunit.MicrosoftTestingPlatform.XunitException: System.IO.FileNotFoundException : Could not find file '…/api/tests/Bookstore.Api.Tests/bin/Release/net10.0/TestData/books.json'.
[api-ci/build-test] | Test run summary: Failed!
[api-ci/build-test] | total: 7
[api-ci/build-test] | failed: 1
[api-ci/build-test] | succeeded: 6
[api-ci/build-test] ❌ Failure - Main Unit tests [1.236229959s]
Windows ve macOS'ta geliştirip Linux'ta test eden her ekip bu hatayı en az bir kez yaşar. Dosya adlarını kodda tam olarak diskteki gibi yaz; CI'ın Linux'ta koşması burada bir avantaj.
SDK roll-forward sürprizi. global.json yoksa .NET, makinede kurulu en yeni SDK'yı kullanır. Runner image'ı güncellenip yeni bir SDK geldiğinde build'in hiçbir değişiklik yapmadan farklı bir derleyiciyle çalışmaya başlaması bu yüzden. global.json + setup-dotnet ikilisi bunu kontrol altına alır.
--no-build ile farklı configuration. Build -c Release ile yapıldıysa test de -c Release ile çalışmalı; aksi hâlde --no-build Debug çıktısını arar ve bulamaz.
Lock dosyasını .gitignore'a eklemek. packages.lock.json repository'de olmalı; olmazsa --locked-mode ve setup-dotnet cache'i çalışmaz.
Artifact'ı her şey için kullanmak. Bin klasörünü job'lar arasında taşımak çoğu zaman yeniden derlemekten yavaştır; artifact'lar kalıcı sonuçlar (rapor, publish çıktısı) içindir.
Küçük Bir Deneme
★☆☆ global.json'dan test bölümünü sil ve dotnet test --project ... komutunun ne söylediğine bak. Sonra geri ekle.
★★☆ --report-xunit-junit ile bir JUnit raporu daha üret ve test-summary.py'ın yanına, JUnit dosyasındaki en yavaş üç testi listeleyen birkaç satır ekle.
★★★ Integration test projesini Testcontainers ile çalışacak şekilde düzenle: ConnectionStrings__Bookstore tanımlıysa onu kullansın, değilse kendi PostgreSQL container'ını başlatsın. İki yolu da act ile dene ve süreleri karşılaştır.
API tarafının CI'ı hazır. Bir sonraki yazıda aynı disiplini Angular uygulamasına uyguluyoruz: setup-node@v7 ile Node sürümünü package.json'dan okumak, npm ci, ESLint, Vitest, build budget'ları ve API'yi PostgreSQL ile birlikte ayağa kaldıran Playwright e2e testleri.