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

GitHub Actions Trigger'ları: push, pull_request, workflow_dispatch, schedule ve Filtreler

Mert Özen 8 Kas 2026 12 dk 5 görüntülenme
GitHub Actions Trigger'ları: push, pull_request, workflow_dispatch, schedule ve Filtreler

Workflow'u ne zaman çalıştıracağını on altındaki event'ler ve filtreler belirler: push ve pull_request için branches, tags, paths ve ! negation; activity type'lar, workflow_dispatch input'ları, timezone destekli schedule, workflow_run, repository_dispatch ve merge_group. github.event.inputs ile boolean okuma tuzağını, actionlint'in yakaladığı trigger hatalarını ve act'in filtreleri neden değerlendirmediğini 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) ve actions/checkout@v7 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 ekibi staging'e elle deploy edebilmek için bir "Run workflow" düğmesi istiyor. Ada, yanlışlıkla production'a deploy edilmesin diye varsayılanı true olan bir dry-run input'u ekliyor ve gerçek deploy step'ini onunla koruyor:

name: manual-deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Target environment
        type: environment
        required: true
      configuration:
        description: Build configuration
        type: choice
        options: [Release, Debug]
        default: Release
      dry-run:
        description: Only print what would be deployed
        type: boolean
        default: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy (guarded by github.event.inputs)
        if: ${{ github.event.inputs.dry-run }}
        run: echo "dry run, nothing deployed"

      - name: Deploy for real
        if: ${{ !inputs.dry-run }}
        run: echo "deploying ${{ inputs.configuration }} to ${{ inputs.environment }}"

Alan düğmeye basıyor, environment olarak staging seçiyor ve dry-run kutusundaki işareti kaldırıyor. Hangi step'ler koşar? Sadece "Deploy for real" mi, ikisi birden mi?

act ile aynı input'ları verip bakalım:

$ act workflow_dispatch --input environment=staging --input dry-run=false
[manual-deploy/deploy] ⭐ Run Main Deploy (guarded by github.event.inputs)
[manual-deploy/deploy]   | dry run, nothing deployed
[manual-deploy/deploy]   ✅  Success - Main Deploy (guarded by github.event.inputs) [67.747ms]
[manual-deploy/deploy] ⭐ Run Main Deploy for real
[manual-deploy/deploy]   | deploying Release to staging
[manual-deploy/deploy]   ✅  Success - Main Deploy for real [63.214541ms]
[manual-deploy/deploy] 🏁  Job succeeded

İkisi de koştu. Sebep iki context arasındaki fark: inputs context'i boolean input'u boolean olarak tutar, ama github.event.inputs aynı değeri string'e çevirir. 'false' boş olmayan bir string olduğu için if: içinde doğru sayılır. Bu örnekte zarar küçük, ama tersini düşün: "dry run değilse sil" diye yazılmış bir temizlik step'i. Kural: workflow_dispatch input'larını her zaman inputs. ile oku. Bu yazıda workflow'ları neyin, ne zaman ve hangi filtrelerle tetiklediğine bakacağız.

push ve pull_request Filtreleri: branches, tags, paths ve ! ile Hariç Tutmak

İlk yazıdaki ci.yml her push'ta ve her pull request'te API'yi (Application Programming Interface) derliyordu. Monorepo'da bu israf: web/ altındaki bir CSS (Cascading Style Sheets) değişikliği için .NET derlemenin anlamı yok. Filtrelerle daralttığımız hâli:

name: ci

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'api/**'
      - 'global.json'
      - '.github/workflows/ci.yml'
      - '!api/**/*.md'
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]
    paths:
      - 'api/**'
      - 'global.json'
      - '.github/workflows/ci.yml'
      - '!api/**/*.md'
  merge_group:
    types: [checks_requested]
  workflow_dispatch:
  schedule:
    - cron: '30 2 * * 1-5'
      timezone: 'Europe/Istanbul'

jobs:
  build:
    if: ${{ !github.event.pull_request.draft }}
    runs-on: ubuntu-latest
    steps:
      - name: Why am I running?
        env:
          HEAD_REF: ${{ github.head_ref }}
        run: echo "event=${{ github.event_name }} ref=${{ github.ref }} head_ref=$HEAD_REF base_ref=${{ github.base_ref }}"

      - 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

branches ve tags glob pattern kabul eder: release/** hem release/1.4'ü hem release/2026/q4'ü yakalar; tek * ise / karakterini geçmez. Dokümantasyondaki üç kural sık gözden kaçıyor. Birincisi: push altında yalnız branches tanımlarsan workflow tag push'larında çalışmaz, yalnız tags tanımlarsan branch push'larında çalışmaz; ikisini de tanımlamazsan ikisinde de çalışır. İkincisi: hem branch hem path filtresi varsa workflow ancak ikisi birden sağlanınca koşar. Üçüncüsü: path filtreleri tag push'larında hiç değerlendirilmez.

paths listesinde sıra önemli. ! ile başlayan bir pattern kendinden önceki pozitif eşleşmeyi geri alır, ondan sonra gelen pozitif bir pattern ise tekrar dahil eder. Bizim listemizde api/** her şeyi alıyor, !api/**/*.md README'leri dışarıda bırakıyor. Aynı event için paths ile paths-ignore'u (ya da branches ile branches-ignore'u) birlikte kullanamazsın; ikisini birden istiyorsan tek liste ve ! kullanılır.

Workflow dosyasının kendisini de paths'e ekledik. Eklemezsen ci.yml'ı değiştiren bir pull request, değiştirdiğin workflow'u hiç çalıştırmaz ve hatayı merge'den sonra görürsün.

GitHub değişen dosya listesini push'larda iki noktalı (two-dot), pull request'lerde üç noktalı (three-dot) diff ile çıkarıyor. Dokümantasyon bu hesabın sınırlarını da yazıyor: 1.000'den fazla commit içeren bir push'ta ya da diff zaman aşımına uğrarsa workflow her zaman koşar; diff 3.000'den fazla dosya içerir ve eşleşen dosya ilk 3.000'de değilse koşmaz. Büyük bir "format everything" commit'i bu yüzden beklenmedik sonuç verebilir.

pull_request Activity Type'ları, Draft'lar ve merge_group

Bir event'in birden fazla activity type'ı olabilir. pull_request için varsayılan üçlü opened, synchronize (branch'e yeni commit geldi) ve reopened. Biz buna ready_for_review'u ekledik ve job'a bir if: koyduk: draft pull request'lerde CI (Continuous Integration) dakikası harcamayalım, draft "review'a hazır" işaretlendiği anda bir kez koşsun. act'e iki farklı event payload'ı verip deneyelim:

$ act pull_request -e pr-opened.json -W .github/workflows/ci.yml
[ci/build] ⭐ Run Main Why am I running?
[ci/build]   | event=pull_request ref=refs/pull/42/merge head_ref=feature/coupon-codes base_ref=main
...
[ci/build]   ✅  Success - Main Unit tests [1.673902709s]
[ci/build] 🏁  Job succeeded
$ act pull_request -e pr-draft.json -W .github/workflows/ci.yml -v | grep -i skip
[ci/build] [DEBUG] Skipping job 'build' due to '${{ !github.event.pull_request.draft }}'

İlk satır pull request'lerle ilgili önemli bir şeyi gösteriyor: github.ref bir branch değil, refs/pull/42/merge. GitHub, pull request branch'ini base branch ile deneme amaçlı birleştirip o merge commit'ini test eder; yani testlerin yeşil olması "feature branch'in kendisi geçiyor" değil, "main ile birleşince geçiyor" demektir. Branch adı github.head_ref'te, hedef branch github.base_ref'te.

HEAD_REF'i neden env: üzerinden verdik? Çünkü ilk denemede doğrudan run: içine yazdığımızda actionlint şunu söyledi:

.github/workflows/ci.yml:32:86: "github.head_ref" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]

Branch adını pull request'i açan kişi seçer ve içine shell karakterleri koyabilir. Script injection konusunu güvenlik yazısında ayrıntılı işleyeceğiz; şimdilik kural basit: kullanıcı kontrolündeki değerleri run:'a ${{ }} ile gömme, environment variable'dan oku.

Repository'nde merge queue açtıysan bir event daha gerekiyor: merge_group. Pull request queue'ya girdiğinde GitHub geçici bir branch'te bir "merge group" oluşturur ve required check'leri onun için ister. Dokümantasyon açık: required check'leri Actions ile çalıştırıyorsan workflow'a merge_group eklemezsen queue'daki pull request'in check'i hiç raporlanmaz ve merge başarısız olur. Bu event'in şu an tek activity type'ı checks_requested; yine de yazmak, ileride yeni type'lar eklenirse workflow'u daraltılmış tutar.

workflow_dispatch ve Input'lar

Tahmin bölümündeki workflow'un düzeltilmiş hâli step'leri inputs context'iyle koruyor:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Dry run
        if: ${{ inputs.dry-run }}
        run: echo "dry run, would deploy ${{ inputs.configuration }} to ${{ inputs.environment }}"

      - name: Deploy
        if: ${{ !inputs.dry-run }}
        run: echo "deploying ${{ inputs.configuration }} to ${{ inputs.environment }}"
$ act workflow_dispatch --input environment=staging --input dry-run=false
[manual-deploy/deploy] ⭐ Run Main Deploy
[manual-deploy/deploy]   | deploying Release to staging
[manual-deploy/deploy] 🏁  Job succeeded
$ act workflow_dispatch --input environment=production
[manual-deploy/deploy] ⭐ Run Main Dry run
[manual-deploy/deploy]   | dry run, would deploy Release to production
[manual-deploy/deploy] 🏁  Job succeeded

İkinci koşuda dry-run vermedik ve varsayılan true devreye girdi; configuration da Release olarak geldi. Input tipleri string, boolean, number, choice ve environment. choice arayüzde açılır liste olarak çıkar ve değeri string'dir; environment repository'de tanımlı environment'ları listeler (environment'ların kendisi dokuzuncu yazının konusu). Dokümantasyona göre en fazla 25 top-level input tanımlayabilir, toplamda 65.535 karakterlik payload gönderebilirsin.

"Run workflow" düğmesi ancak workflow dosyası default branch'e girince görünür. Dosya bir kez çalıştıktan sonra API ya da GitHub CLI (Command Line Interface) ile başka branch'ler veya tag'ler üzerinde de tetikleyebilirsin: gh workflow run manual-deploy.yml --ref release/1.4 -f environment=staging.

schedule: Cron, UTC ve Mart 2026'da Gelen timezone

ci.yml'daki schedule girdisi hafta içi her gece 02.30'da (İstanbul saatiyle) bir build tetikliyor. Gece build'leri, kimse push'lamasa da dışarıdaki değişiklikleri yakalamak için var: yeni bir NuGet paketi, değişen bir runner image'ı, bir sertifikanın süresinin dolması. Cron beş alanlı POSIX (Portable Operating System Interface) syntax'ı: dakika, saat, ayın günü, ay, haftanın günü.

Mart 2026'ya kadar schedule'lar yalnız UTC (Coordinated Universal Time) ile çalışıyordu; "gece 02.30" demek için kafandan saat farkı hesaplayıp yaz saati değişince cron'u güncellemen gerekiyordu. 19 Mart 2026 changelog'uyla cron'un yanına IANA (Internet Assigned Numbers Authority) saat dilimi adı alan bir timezone alanı geldi. Türkiye yaz saati uygulamadığı için bizim örnekte fark sabit üç saat, ama Europe/Berlin gibi yaz saati uygulayan bir bölgede dokümantasyondaki notu bil: ileri saat geçişinde atlanan saate denk gelen schedule bir sonraki geçerli saate kayar (02.30'luk iş 03.00'te koşar).

Schedule'ın birkaç sınırı var ve hepsi dokümantasyonda yazıyor. En kısa aralık 5 dakika. Schedule her zaman default branch'in son commit'inde koşar. Yoğun saatlerde, özellikle saat başlarında, gecikebilir; yük çok yüksekse bazı kuyruktaki job'lar düşürülebilir, bu yüzden 0 * * * * yerine saat başından uzak bir dakika seç. Public repository'de 60 gün hiçbir aktivite olmazsa scheduled workflow'lar otomatik olarak kapatılır. Hangi cron satırının tetiklediğini github.event.schedule ile okuyabilirsin.

Filtreleri Doğrulamak: actionlint Ne Yakalar, act Ne Yakalamaz

Trigger hataları sinsi, çünkü yanlış bir filtre hata vermez; workflow sadece çalışmaz. actionlint bu tür hataların çoğunu push'tan önce yakalıyor. Bilerek bozduğumuz bir dosya:

on:
  push:
    branches: [main]
    branches-ignore: ['dependabot/**']
  pull_request:
    types: [opened, synchronized]
  schedule:
    - cron: '0 25 * * *'
    - cron: '*/2 * * * *'
  workflow_dispatch:
    inputs:
      configuration:
        type: choice
        options: [Release, Debug]
        default: Staging
$ actionlint .github/workflows/bad-triggers.yml
.github/workflows/bad-triggers.yml:6:5: both "branches" and "branches-ignore" filters cannot be used for the same event "push". note: use '!' to negate patterns [events]
.github/workflows/bad-triggers.yml:8:21: invalid activity type "synchronized" for "pull_request" Webhook event. available types are "assigned", "auto_merge_disabled", "auto_merge_enabled", "closed", "converted_to_draft", "demilestoned", "dequeued", "edited", "enqueued", "labeled", "locked", "milestoned", "opened", "ready_for_review", "reopened", "review_request_removed", "review_requested", "synchronize", "unassigned", "unlabeled", "unlocked" [events]
.github/workflows/bad-triggers.yml:10:13: invalid CRON format "0 25 * * *" in schedule event: end of range (25) above maximum (23): 25 [events]
.github/workflows/bad-triggers.yml:11:13: scheduled job runs too frequently. it runs once per 120 seconds. the shortest interval is once every 5 minutes [events]
.github/workflows/bad-triggers.yml:17:18: default value "Staging" of "configuration" input is not included in its options "\"Release\", \"Debug\"" [events]

Beş hatanın beşi de GitHub'da sessizce "workflow hiç tetiklenmedi" ya da "dosya geçersiz" olarak karşına çıkardı. synchronized yazım hatası özellikle tehlikeli: workflow pull request açılınca bir kez koşar, sonraki commit'lerde hiç koşmaz ve bunu fark etmek günler sürebilir. actionlint 1.7.12 yeni timezone alanını da tanıyor; Europe/Istanbull yazınca "invalid timezone ... it must be a valid IANA timezone name" diye uyardı.

act tarafında ise önemli bir sınır var: act workflow'u yalnız event adına göre seçiyor, branches, tags ve paths filtrelerini değerlendirmiyor. Yalnız tags: ['v*'] filtresi olan bir workflow'u main branch'ine push olarak simüle ettiğimizde act onu yine de koşturdu; GitHub'da bu push o workflow'u hiç tetiklemezdi. Yani act'te "koştu" görmek filtrenin doğru olduğunu kanıtlamaz. Filtreleri GitHub'da küçük bir test pull request'iyle doğrulamak gerekiyor.

Workflow'ları Birbirine Bağlamak: workflow_run, repository_dispatch ve pull_request_target

Bazen bir workflow'un bitişi başka bir workflow'u tetiklemeli: CI main'de bitince bir rapor üretmek ya da dış bir sistem katalog importunu bitirince bir kontrol çalıştırmak gibi. İkisi için ayrı event'ler var:

name: after-ci

on:
  workflow_run:
    workflows: [ci]
    types: [completed]
    branches: [main]
  repository_dispatch:
    types: [catalog-imported]

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - name: CI finished
        if: ${{ github.event_name == 'workflow_run' }}
        run: echo "ci on ${{ github.event.workflow_run.head_branch }} finished with ${{ github.event.workflow_run.conclusion }}"

      - name: Catalog imported
        if: ${{ github.event_name == 'repository_dispatch' }}
        run: echo "catalog import ${{ github.event.client_payload.batch }} has ${{ github.event.client_payload.books }} books"
$ act workflow_run -e workflow-run.json
[after-ci/report]   | ci on main finished with failure
$ act repository_dispatch -e repo-dispatch.json
[after-ci/report]   | catalog import 2026-11-08 has 1250 books

completed başarı demek değil, "bitti" demek; sonucu github.event.workflow_run.conclusion'dan kontrol etmen gerekir, act çıktısında gördüğün gibi failure da gelebilir. workflow_run yalnız default branch'teki dosyayla çalışır ve zincir en fazla üç seviye derinliğe izin verir. repository_dispatch ise dışarıdan, REST (Representational State Transfer) API'ye yapılan bir çağrıyla tetiklenir; client_payload'da en fazla 10 top-level property olabilir.

Bir tuzak: workflow içinden GITHUB_TOKEN ile yaptığın bir push yeni bir workflow tetiklemez. Bu, sonsuz döngüleri engellemek için bilinçli bir kural. İstisnalar workflow_dispatch ve repository_dispatch; ayrıca dokümantasyona göre GITHUB_TOKEN ile açılan ya da güncellenen pull request'lerin opened, synchronize ve reopened event'leri artık onay bekleyen run'lar oluşturuyor.

Son olarak pull_request_target. Adı benziyor ama davranışı çok farklı: fork'tan gelen pull request'te pull_request fork'un kodunu salt okunur token ve secret'sız çalıştırırken, pull_request_target base repository'nin default branch'i bağlamında, secret'larla ve yazma yetkili token'la çalışır. Bu bağlamda fork'un kodunu checkout edip build etmek "pwn request" denen saldırının ta kendisi. actions/checkout@v7 bu yüzden pull_request_target ve workflow_run altında fork kodunu varsayılan olarak checkout etmeyi reddediyor; bilerek açmak için allow-unsafe-pr-checkout: true yazman gerekiyor. Güvenli pattern'leri güvenlik yazısında ayrıntılı göreceğiz; o zamana kadar CI için pull_request kullan.

Üretimde Dikkat

Filtrelerin amacı maliyet ve süre. Bookstore'da web/ değişikliklerinin yarısından fazlası API'ye dokunmuyorsa, path filtresi o pull request'lerde .NET job'ını tamamen atlar. Bunu tahminle değil ölçümle yap: bir ay boyunca Actions sekmesinde workflow başına run sayısını ve süresini not et, filtreden sonra tekrar bak.

Ama filtrelerin bir bedeli var: atlanan bir workflow "başarılı" değil "hiç koşmamış" sayılır. Tuzaklar bölümünde göreceğin required check sorunu bunun en sık görülen sonucu. Bunun için monorepo yazısında "her zaman koşan özet job" pattern'ini kuracağız.

Schedule'lar için de bir sahip belirle. Gece kırılan bir build'i kimse görmüyorsa o build gürültüden ibaret. Başarısızlıkta bildirim gönderen bir step ya da kırık gece build'ini sabah stand-up'ında açan bir alışkanlık, schedule'ı işe yarar hâle getirir.

Sık Düşülen Tuzaklar

Path filtresiyle atlanan required check. build job'ını main için required check yaptın ve ci.yml'a paths: ['api/**'] ekledin. Yalnız web/ değiştiren bir pull request geldiğinde workflow tetiklenmez ve dokümantasyonun açıkça yazdığı gibi check "Pending" durumunda kalır; pull request merge edilemez. Çözüm ya check'i required yapmamak ya da filtreyi workflow seviyesinden job seviyesine taşıyıp her zaman koşan bir özet job'ı required yapmak.

github.event.inputs ile boolean okumak. Tahmin bölümünde gördün: string 'false' doğru sayılır. inputs. kullan.

Yazım hatalı activity type. synchronized, open, merged gibi var olmayan type'lar GitHub'da hata vermeden filtreyi boşa düşürebilir. actionlint bunları yakalıyor.

Workflow dosyasını paths'e eklememek. Workflow'u değiştiren pull request kendi değişikliğini test etmez.

UTC'yi unutmak. timezone yazmadığın her cron UTC'dir. "Sabah 9'da" diye yazdığın 0 9 * * * İstanbul'da öğlen 12'de koşar.

act'te filtre testi yapmak. act branches/tags/paths filtrelerini uygulamaz; act'te çalışan workflow GitHub'da hiç tetiklenmeyebilir.

Küçük Bir Deneme

★☆☆ ci.yml'ın paths listesine !api/**/*.md'den sonra api/docs/CHANGELOG.md'yi yeniden dahil eden bir satır ekle. Sıranın neden önemli olduğunu kendi cümlenle yaz.

★★☆ manual-deploy.yml'a number tipinde bir replicas input'u ekle. act ile --input replicas=3 vererek çalıştır ve inputs.replicas ile github.event.inputs.replicas'ı yan yana yazdır.

★★★ Bu yazıdaki bad-triggers.yml'a kendi hatalarını ekle: geçersiz bir glob, tags ile tags-ignore birlikte, var olmayan bir event adı. actionlint'in hangilerini yakaladığını, hangilerini kaçırdığını not et.

Bu yazıda ${{ }} ile birçok değer okuduk: github.ref, inputs.dry-run, github.event.workflow_run.conclusion. Bir sonraki yazıda bu syntax'ın arkasındaki expression dilini, context'leri, fonksiyonları ve step'ler arasında veri taşımanın yollarını (GITHUB_OUTPUT, GITHUB_ENV, GITHUB_STEP_SUMMARY) açacağız.