GitHub Actions Nedir? Workflow, Event, Job, Step ve Runner ile İlk CI
GitHub Actions, repository'deki event'lere göre .github/workflows altındaki YAML workflow'larını runner'larda koşturan CI/CD platformudur. Bookstore API için restore, build ve unit test yapan ilk workflow'u yazıp act ve actionlint ile yerelde deniyor; uses ile run farkını, run log'unu, re-run'ı ve Web API 31'deki workflow'un nedenlerini konuşuyoruz.
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), actions/checkout@v7 ve actions/setup-dotnet@v6 ile denenmiştir. Workflow'lar act ile yerelde koşuldu; GitHub'da koşulmadı.
Önce Tahmin Et
Ada, Bookstore repository'sine ilk workflow'unu ekliyor. İnternetten bulduğu bir örneği kopyalıyor, iki satırı kendi projesine göre değiştiriyor ve dosyayı .github/workflows/ci.yml olarak kaydediyor:
name: ci
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build
run: dotnet build api/Bookstore.slnx
Dosyada tek bir hata var ve gözle bulmak zor. Soru şu: bu dosya main'e push'landığında build hangi adımda kırmızıya döner? Checkout'ta mı, build'de mi, yoksa hiç başlamaz mı?
Push'lamadan önce aynı dosyayı actionlint'e verelim:
$ actionlint .github/workflows/ci.yml
.github/workflows/ci.yml:10:6: could not parse as YAML: did not find expected '-' indicator [syntax-check]
|
10 | steps:
| ^~~~~
Cevap: hiç başlamaz. run: satırı - name: Build ile aynı hizada duruyor; YAML (YAML Ain't Markup Language) parser'ı bunu steps listesinin yeni bir elemanı sanıyor ama başında - yok. Dosya geçerli bir YAML bile olmadığı için GitHub hiçbir job'ı runner'a göndermez; Actions sekmesinde kırmızı bir "workflow file" hatası görürsün ve hiçbir step koşmaz. run: iki boşluk içeri alınınca sorun kalkıyor. Bu yazıda bu tür hataları push'lamadan önce yakalamayı da göreceğiz.
GitHub Actions'ın Parçaları: Event, Workflow, Job, Step ve Runner
GitHub Actions, repository'nde bir şey olduğunda (bir push, açılan bir pull request, bir tag, bir zamanlayıcı) senin tanımladığın işleri GitHub'ın verdiği ya da senin bağladığın makinelerde çalıştıran bir CI (Continuous Integration) ve CD (Continuous Delivery) platformu. Beş kavramı oturtursan geri kalan her şey bunların üstüne kurulur.

Event, workflow'u tetikleyen şeydir: push, pull_request, workflow_dispatch (elle çalıştırma), schedule gibi. Workflow, .github/workflows/ klasöründeki her bir YAML dosyasıdır; bir repository'de istediğin kadar workflow olabilir ve her biri kendi event'lerini dinler. Job, bir workflow'un içindeki bağımsız iş birimidir; varsayılan olarak job'lar paralel koşar, sıralamak istersen needs ile bağlarsın (dördüncü yazının konusu). Step, bir job'ın içindeki sıralı adımlardır; bir step ya hazır bir action çağırır (uses:) ya da bir shell komutu çalıştırır (run:). Runner ise job'ı çalıştıran makinedir.
En önemli ayrım job ile step arasında. Aynı job'ın step'leri aynı makinede, aynı dosya sisteminde, sırayla koşar: checkout'un indirdiği kod build step'inde hazırdır. İki farklı job ise iki farklı makinede koşar ve birbirinin dosyasını görmez; bir job'ın ürettiğini diğerine taşımak için artifact gerekir (yedinci yazı). GitHub'ın kendi runner'larında, tek CPU'lu (Central Processing Unit) küçük runner'lar hariç, her job için yeni bir VM (Virtual Machine) açılır ve job bitince atılır. Bu yüzden önceki koşudan kalan bir bin/ klasörüne, kurulu bir global tool'a ya da ısınmış bir cache'e güvenemezsin; her koşu sıfırdan başlar.
İlk Workflow: Bookstore API İçin Restore, Build ve Test
Bu serinin örnek repository'si bir monorepo: api/ klasöründe Bookstore.Api (ASP.NET Core 10 minimal API (Application Programming Interface), Entity Framework Core (EF Core) ve PostgreSQL), unit test ve integration test projeleri; web/ klasöründe Angular 22 ile yazılmış bookstore-web. Kökte bir global.json hangi .NET SDK'sının (Software Development Kit) kullanılacağını söylüyor. İlk workflow yalnızca API'yi derleyip unit testleri çalıştıracak:
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Restore
run: dotnet restore api/Bookstore.slnx
- name: Build
run: dotnet build api/Bookstore.slnx --no-restore -c Release
- name: Unit tests
run: dotnet test --project api/tests/Bookstore.Api.Tests --no-build -c Release
name: ci Actions sekmesinde workflow'un görünen adı. on hangi event'lerin bu workflow'u tetikleyeceğini söylüyor: main'e yapılan her push ve main'e açılan her pull request. jobs altında tek bir job var: build. Bu ad job'ın id'si; ileride needs: build dediğinde ve branch protection'da required check seçtiğinde bu adı göreceksin.
runs-on: ubuntu-latest job'ın hangi runner image'ında koşacağını seçiyor. Kasım 2026 itibarıyla bu label hareket hâlinde: GitHub, ubuntu-latest'i 19 Ekim ile 19 Kasım 2026 arasında kademeli olarak Ubuntu 24.04'ten 26.04'e taşıyor. Yani aynı workflow geçen ay 24.04'te, bu ay 26.04'te koşmuş olabilir. Bu geçişin ayrıntısı ve ubuntu-24.04'e pin'lemenin ne zaman mantıklı olduğu runner'lar yazısında; şimdilik "latest" kelimesinin sabit bir şey olmadığını akılda tut.
Step'lere gelince: actions/checkout@v7 repository'yi runner'a çekiyor. Runner boş bir makine; checkout olmadan workspace'te tek bir dosya yok. actions/setup-dotnet@v6 global.json'u okuyup oradaki kurala uyan SDK'yı kuruyor. Bizim global.json'umuz şöyle:
{
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
latestFeature "10.0.400 veya üstündeki en yeni 10.0 SDK'sı" demek; setup-dotnet de README'sinde söylediği gibi bu kuralı .NET'in kendi çözümleyicisi gibi yorumlayıp en yüksek uygun SDK'yı kuruyor. test bölümü ise .NET 10'daki dotnet test'i Microsoft.Testing.Platform moduna alıyor; xUnit v3'ün güncel sürümü bunu zorunlu kılıyor. Bu ayrıntıya .NET CI yazısında döneceğiz. Geri kalan üç step, yerelde de yazdığın komutlar: restore, Release'te build, unit test. --no-restore ve --no-build aynı işi iki kez yapmamak için var; adımları ayırmanın asıl faydası ise log'da hangi adımın kırıldığını tek bakışta görmek.
uses: ve run: Arasındaki Fark
run: runner'ın shell'inde komut çalıştırır; Linux runner'da varsayılan shell bash'tir ve GitHub onu -e ile çağırır, yani ilk başarısız komutta step durur. uses: ise başkasının paketlediği bir action'ı çağırır. Action üç türlü olabilir: JavaScript action (runner'daki Node.js ile koşar; checkout ve setup-dotnet böyle), Docker container action ve birden fazla step'i paketleyen composite action. actions/checkout@v7 ifadesi "github.com/actions/checkout repository'sinin v7 ref'indeki action" demek; with: altındaki değerler o action'ın action.yml dosyasında tanımlı input'lara gider.
Bunu act'in log'unda açıkça görebilirsin. act, uses: satırını görünce action'ın repository'sini klonluyor ve içindeki JavaScript dosyasını Node ile çalıştırıyor; run: satırı ise bir bash script'ine dönüşüyor:
[ci/build] ☁ git clone 'https://github.com/actions/setup-dotnet' # ref=v6
[ci/build] ⭐ Run Main actions/setup-dotnet@v6
[ci/build] 🐳 docker exec cmd=[/opt/acttoolcache/node/24.19.0/arm64/bin/node /var/run/act/actions/actions-setup-dotnet@v6/dist/setup/index.js] user= workdir=
...
[ci/build] ⭐ Run Main Restore
[ci/build] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
Buradan çıkan ders: uses: ile çağırdığın her action, CI makinende senin yetkilerinle koşan üçüncü taraf koddur. GitHub Marketplace'te binlerce action var ve bir kısmı tek kişinin hobi projesi. Seçerken sırayla şunlara bak: action'ı kim yayınlıyor (GitHub'ın kendi actions/ organization'ı mı, aracın resmi sahibi mi), kaynak kodu okunabilir mi, son release ne zaman, ve gerçekten gerekli mi. İki satırlık bir run: ile yapabileceğin iş için action eklemek, saldırı yüzeyine bir bağımlılık daha eklemektir. Tag yerine commit SHA'sına (Secure Hash Algorithm) pin'lemek ve bunu Dependabot ile güncel tutmak güvenlik yazısının konusu.
Workflow'u Yerelde Koşmak: actionlint ve act
"Push'la ve bak" döngüsü yavaş ve repository geçmişini "fix ci", "fix ci again" commit'leriyle doldurur. İki araç bu döngüyü kısaltıyor. actionlint (rhysd/actionlint) workflow dosyalarını statik olarak denetler: YAML syntax'ını, bilinmeyen key'leri, expression'ları, job bağımlılıklarını, hatta run: içindeki shell script'lerini. act (nektos/act) ise workflow'u Docker container'ları içinde gerçekten koşturur. İkisi de tek binary; bu seride ikisini de repository'ye değil çalışma klasörüne indirdik.
$ actionlint && echo LINT-OK
LINT-OK
$ act -l
Stage Job ID Job name Workflow name Workflow file Events
0 build build ci ci.yml push,pull_request
act -l hangi workflow'ların, hangi job'ların ve hangi event'lerin tanındığını listeliyor. Şimdi push event'ini simüle edelim. act'e runner image'ı olarak catthehacker/ubuntu:act-24.04 verdik; bu image GitHub'ın runner image'ının küçültülmüş bir taklidi, birebir aynısı değil. Gerçek komutta cache ve artifact klasörlerini çalışma klasöründe tutan birkaç bayrak daha vardı; özü aşağıdaki satır. Log'un önemli kısmı (uzun yerel yolları … ile kısalttım):
$ act push -P ubuntu-latest=catthehacker/ubuntu:act-24.04
[ci/build] ⭐ Run Set up job
[ci/build] 🚀 Start image=catthehacker/ubuntu:act-24.04
[ci/build] ✅ Success - Set up job
[ci/build] ⭐ Run Main actions/checkout@v7
[ci/build] ✅ Success - Main actions/checkout@v7 [326.932875ms]
[ci/build] ⭐ Run Main actions/setup-dotnet@v6
[ci/build] | [command]/run/act/actions/actions-setup-dotnet@v6/externals/install-dotnet.sh --skip-non-versioned-files --runtime dotnet --channel LTS
[ci/build] | dotnet-install: Installed version is 10.0.14
[ci/build] | [command]/run/act/actions/actions-setup-dotnet@v6/externals/install-dotnet.sh --skip-non-versioned-files --channel 10.0
[ci/build] | dotnet-install: Installed version is 10.0.403
[ci/build] ✅ Success - Main actions/setup-dotnet@v6 [13.840332625s]
[ci/build] ⚙ ::set-output:: dotnet-version=10.0.403
[ci/build] ⭐ Run Main Restore
[ci/build] | Restored …/bookstore/api/src/Bookstore.Api/Bookstore.Api.csproj
[ci/build] ✅ Success - Main Restore [5.5987835s]
[ci/build] ⭐ Run Main Build
[ci/build] | Build succeeded.
[ci/build] | 0 Warning(s)
[ci/build] | 0 Error(s)
[ci/build] ✅ Success - Main Build [2.523338625s]
[ci/build] ⭐ Run Main Unit tests
[ci/build] | Test run summary: Passed!
[ci/build] | total: 6
[ci/build] | failed: 0
[ci/build] | succeeded: 6
[ci/build] | skipped: 0
[ci/build] | duration: 818ms
[ci/build] ✅ Success - Main Unit tests [1.297411833s]
[ci/build] 🏁 Job succeeded
Log'u okurken birkaç şeye dikkat et. setup-dotnet global.json'daki 10.0.400 + latestFeature kuralından 10.0.403'ü seçti ve bunu bir step output'u (dotnet-version) olarak yayınladı. Ondan önce bir de LTS (Long Term Support) runtime kurdu; bu, action'ın varsayılan "LTS pre-pass" davranışı ve skip-lts-prepass input'uyla kapatılabiliyor. Toplam süre bu makinede 35 saniye civarındaydı ve bunun 14 saniyesi SDK indirmesiydi; GitHub'ın hosted runner'ında bazı SDK'lar image'da hazır geldiği için bu oran farklı olur.
Şimdi asıl işini görelim. Ada bir feature branch'te kargo ücretini 29,90'dan 24,90'a indiriyor ama testleri güncellemeyi unutuyor:
[ci/build] ⭐ Run Main Unit tests
[ci/build] | failed Bookstore.Api.Tests.OrderPricingTests.Small_order_pays_shipping (1ms)
[ci/build] | Assert.Equal() Failure: Values differ
[ci/build] | Expected: 29.90
[ci/build] | Actual: 24.90
[ci/build] | failed Bookstore.Api.Tests.OrderPricingTests.Discount_can_bring_order_below_free_shipping (0ms)
[ci/build] | Test run summary: Failed!
[ci/build] | total: 6
[ci/build] | failed: 2
[ci/build] | succeeded: 4
[ci/build] | Test run completed with non-success exit code: 2 (see: https://aka.ms/testingplatform/exitcodes)
[ci/build] ❌ Failure - Main Unit tests [1.737119666s]
[ci/build] exitcode '2': failure
[ci/build] 🏁 Job failed
Error: Job 'build' failed
Zincir şöyle işliyor: dotnet test 2 exit code'uyla çıktı, sıfır olmayan exit code step'i başarısız yaptı, başarısız step job'ı başarısız yaptı, job da workflow'u. GitHub'da bu, pull request'in altında kırmızı bir çarpı ve branch protection varsa kilitli bir merge butonu demek. Bütün CI mantığı bu kadar basit bir kurala dayanıyor: exit code sıfır değilse dur.
Actions Sekmesi, Run Log'u ve Re-run
GitHub'da her tetiklenme bir "workflow run" oluşturur ve repository'nin Actions sekmesinde listelenir. Bir run'ı açtığında solda job'ları, sağda seçtiğin job'ın step'lerini görürsün; her step açılıp kapanabilen bir log bölümüdür ve act'teki ⭐ Run Main Build satırlarının GitHub'daki karşılığı bu bölümlerdir. Başarısız step otomatik açılır. Log satırlarına link verebilir, log'u indirebilirsin. Test raporunu log'a gömülü metin olarak değil özet tablo olarak görmek de mümkün; bunu expression yazısında GITHUB_STEP_SUMMARY ile yapacağız.
Kırmızı bir run'da iki düğme işine yarar: "Re-run all jobs" ve "Re-run failed jobs". Dokümantasyona göre bir run'ı ilk koşusundan sonraki 30 gün içinde ve en fazla 50 kez yeniden çalıştırabilirsin. Re-run, orijinal event'in GITHUB_SHA ve GITHUB_REF değerleriyle koşar; yani branch'e yeni commit attıysan re-run onu almaz, eski commit'i tekrar dener. Re-run sırasında "Enable debug logging" seçeneği runner ve step debug log'larını açar; hata ayıklama yazısında buna ayrıca bakacağız. Re-run'ın doğru kullanımı geçici hatalardır (NuGet'in kısa bir kesintisi gibi). Bir test bazen geçip bazen kalıyorsa re-run düğmesine basmak sorunu çözmez, sadece saklar.
Web API 31'deki Workflow'u Yeniden Okumak
ASP.NET Core Web API serisinin "ASP.NET Core Web API'yi Docker'a Almak ve GitHub Actions ile CI/CD Kurmak" yazısı iki job'lı kısa bir workflow göstermişti. Artık her satırın nedenini konuşabiliriz. İlk job (build-and-test) bizim build job'ımızın neredeyse aynısı; tek farkı SDK sürümünü dotnet-version: 6.0.x ile workflow'a yazması. Bu yazıda sürümü global.json'a taşıdık; böylece geliştiricinin makinesi ile CI aynı dosyadan beslenir ve sürüm yükseltmek tek bir yerde değişiklik ister.
İkinci job (docker) üç önemli fikir taşıyordu. needs: build-and-test: testler geçmeden image üretme. if: github.event_name == 'push': pull request'lerde image push'lama, yalnız main'e merge edilmiş kodu yayınla. permissions altındaki packages: write: GHCR'a (GitHub Container Registry) yazabilmek için GITHUB_TOKEN'a yalnız bu job'da ek yetki ver. Bu üç fikrin her birine bu seride ayrı yazı ayırdık: job bağımlılıkları dördüncü, expression ve if üçüncü, permission'lar sekizinci yazıda. O yazının Dockerfile'ı ve image push kısmını tekrar etmeyeceğiz; Linux sunucuya deploy yazısında oradaki image'ı alıp ileri götüreceğiz.
Üretimde Dikkat
İlk workflow'un amacı hız değil güven: main'deki kodun her an derlenebilir ve testlerinin yeşil olduğunu bilmek. Bunu ölçmenin en basit yolu Actions sekmesinde son 20 run'a bakmak. Kırmızılar çoğunlukla "biri kırdı ve hemen düzeltti" ise CI işini yapıyor. Kırmızılar rastgele dağılıyor ve re-run ile yeşile dönüyorsa flaky test ya da dış bağımlılık sorunun var demektir; bunu görmezden gelmek, ekibin kırmızıya "yine mi" diye bakmaya başlaması demek.
Süreyi de baştan izle. Bu küçük workflow act'te yarım dakika sürdü; proje büyüdükçe restore ve test süresi artacak. Pull request başına 10 dakikayı geçen bir CI, geliştiricinin beklerken başka işe geçmesine ve geri bildirimin değerini yitirmesine yol açar. Cache, paralel job ve path filtreleri sonraki yazılarda bu süreyi aşağıda tutmak için var. Maliyet tarafında public repository'lerde standart hosted runner'lar ücretsiz; private repository'lerde dakikalar planının kotasından düşer.
Son olarak, workflow dosyası da koddur: pull request'le değişir, review edilir, geçmişi tutulur. main'e doğrudan workflow push'layan biri, CI'ın ne kontrol ettiğini tek başına değiştirebilir. Git & GitHub serisinin "Git ile Ekip Çalışması: Branch Stratejileri, Rulesets ve Conventional Commits" yazısındaki ruleset'lerle main'i korumak ve build job'ını required check yapmak bu workflow'u gerçekten bağlayıcı hâle getirir.
Sık Düşülen Tuzaklar
YAML girintisi. Tahmin bölümündeki hata en yaygın olanı: tab karakteri, bir boşluk eksik ya da fazla. YAML'da girinti yapının kendisidir. Editöründe YAML desteğini aç ve push'lamadan önce actionlint çalıştır.
on key'ini genel amaçlı bir YAML kütüphanesiyle okumak. YAML 1.1'de on, yes ve true boolean sayılır. Workflow dosyalarını kendi script'inle işlemeye kalkarsan sürprizle karşılaşırsın:
$ python -c "import yaml; print(list(yaml.safe_load(open('.github/workflows/ci.yml')).keys()))"
['name', True, 'jobs']
PyYAML 6.0.3 on key'ini True'ya çevirdi. GitHub'ın kendi parser'ı bunu doğru yorumluyor, ama workflow'ları otomatik düzenleyen bir araç yazıyorsan key'i "on" diye tırnaklamak ya da YAML 1.2 parser'ı kullanmak gerekir.
Workflow'un default branch'te olmaması. Bir feature branch'te yeni bir workflow eklediğinde push ve pull_request o branch'teki dosyayla çalışır. Ama workflow_dispatch ile gelen "Run workflow" düğmesi ve schedule ancak dosya default branch'e girince görünür; dokümantasyon bu event'ler için bunu açıkça yazıyor. "Butonu göremiyorum" şikâyetinin cevabı genellikle budur.
Runner'ın dolu olduğunu varsaymak. Checkout'u unutup dotnet build çalıştırmak "proje bulunamadı" hatası verir; SDK sürümünü belirtmeyip image'da hangisi varsa onunla derlemek de bir gün ubuntu-latest değiştiğinde sessizce farklı bir SDK'ya geçmek demektir.
act'i GitHub sanmak. act workflow'un mantığını yerelde denemek için çok iyi, ama image'ı GitHub'ınkiyle aynı değil, GITHUB_TOKEN yok, OIDC (OpenID Connect) ve environment onayı gibi özellikler yok. "act'te geçti" ile "GitHub'da geçti" arasındaki farkı her zaman aklında tut.
Küçük Bir Deneme
★☆☆ Kendi bir .NET projen için bu yazıdaki ci.yml'ı uyarla. Önce actionlint ile denetle, sonra bir step'in girintisini bilerek boz ve hata mesajını oku.
★★☆ Bir testi bilerek kır ve workflow'u act ile çalıştır. Hangi satırın job'ı kırmızıya çevirdiğini log'da bul; sonra aynı komutu yerelde çalıştırıp exit code'u echo $? ile kontrol et.
★★★ ci.yml'a Angular uygulaması için ikinci bir job ekle (actions/setup-node@v7, npm ci, npm test -- --watch=false). act -l çıktısında iki job'ın aynı stage'de görünmesinin ne anlama geldiğini düşün.
Bu workflow şimdilik yalnız main'e push ve pull request'te çalışıyor. Peki yalnız api/ klasörü değiştiğinde çalışsın, tag'lerde başka bir şey yapsın, gece bir kez kendiliğinden ya da bir düğmeyle elle tetiklensin istersen? Bir sonraki yazıda trigger'lara ve filtrelere tek tek bakacağız.