GitHub Actions'ta Tekrarı Azaltmak: Composite Action, Reusable Workflow ve YAML Anchor
Step'leri paylaşmak için composite action, environment ve approval gerektiren job'ları paylaşmak için reusable workflow, aynı dosyada blok tekrarı için YAML anchor kullan. Bookstore'un dotnet-ci action'ı ve reusable-deploy workflow'u act ile; secrets: inherit riskleri, environment secret'ları, 10 seviye/50 workflow sınırı, $/ syntax'ı, sürümleme ve merge key'in desteklenmemesi.
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. Limitler ve yeni syntax'lar Kasım 2026 itibarıyla GitHub dokümantasyonundan doğrulanmıştır.
Önce Tahmin Et
Bookstore'da deploy adımlarını bir composite action'a taşıdın ve action'ın içinde DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} yazdın. Workflow koşunca token ne olur: environment'tan gelir mi, boş mu kalır, yoksa workflow hiç başlamaz mı?
İkinci soru: staging ve production job'larının env: bloklarını aynı tutmak için YAML'de (YAML Ain't Markup Language) <<: *deploy-env merge key'ini kullandın ve production'da yalnızca APP_URL'i ezdin. GitHub bunu kabul eder mi?
Tekrar Nerede Birikiyor
Seride şu ana kadar yazdığımız workflow'lara bak: api-ci, ci, deploy, deploy-azure, deploy-vm. Hepsinde checkout, setup-dotnet ve restore/build/test üçlüsü var. Deploy job'ları staging ve production için neredeyse satır satır aynı. Bir gün --locked-mode'u eklemeye karar verdiğinde beş dosyayı değiştirmen, birini unutman ve iki hafta sonra "CI'da (Continuous Integration) geçti ama deploy pipeline'ında kırıldı" diye bakman gerekiyor.
GitHub Actions'ta tekrarı azaltmanın üç aracı var ve her biri farklı bir seviyede çalışıyor. Composite action step'leri paketler: bir job'ın içinde tek step gibi görünür. Reusable workflow job'ları paketler: çağıran workflow'da tek job gibi görünür ama kendi runner'ı, environment'ı ve permission'ları vardır. YAML anchor'ları ise aynı dosya içinde bir bloğu tekrar kullanmanı sağlar. Clean Code serisinde konuştuğumuz DRY (Don't Repeat Yourself) ilkesi burada da geçerli, ama aynı uyarıyla: erken soyutlama, tekrarın kendisinden daha pahalı olabilir.
Composite Action: Step'leri Paketlemek
Bookstore'un .NET CI adımları .github/actions/dotnet-ci/action.yml'da:
name: dotnet-ci
description: Restore, build and test the Bookstore API with the SDK pinned in global.json
inputs:
configuration:
description: Build configuration
default: Release
test-project:
description: Test project to run
default: api/tests/Bookstore.Api.Tests
outputs:
sdk-version:
description: The .NET SDK version that setup-dotnet installed
value: ${{ steps.setup.outputs.dotnet-version }}
runs:
using: composite
steps:
- id: setup
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Restore
shell: bash
run: dotnet restore api/Bookstore.slnx --locked-mode
- name: Build
shell: bash
env:
CONFIGURATION: ${{ inputs.configuration }}
run: dotnet build api/Bookstore.slnx --no-restore -c "$CONFIGURATION"
- name: Unit tests
shell: bash
env:
CONFIGURATION: ${{ inputs.configuration }}
TEST_PROJECT: ${{ inputs.test-project }}
run: dotnet test --project "$TEST_PROJECT" --no-build -c "$CONFIGURATION"
Workflow'dan farklı birkaç kural var. run: içeren her step'te shell: zorunlu; workflow'daki defaults.run.shell buraya geçmez. Input'lar inputs context'iyle okunur ve hepsi string'dir. Output, içerideki bir step'in output'una value: ile bağlanır. Step'lerde if: ve continue-on-error kullanılabilir. Ve tahminin ilk sorusunun cevabı: composite action'da secrets context'i yok. Secret gerekiyorsa çağıran workflow onu bir input olarak (veya step'in env:'iyle) vermek zorunda. actionlint yerel bir composite action'ın içindeki expression'ları denetlemediği için bu hatayı dosyayı yazarken fark etmezsin; act ise şemasıyla anında yakaladı: Failed to match run-step: Line: 9 Column 23: Unknown Variable Access secrets. Ters yönde bir fark da gördüm: shell:'i olmayan bir composite step'i act sorunsuz çalıştırdı, oysa GitHub bu alanı zorunlu tutuyor. Yerel koşunun geçmesi, GitHub'da geçeceğinin kanıtı değil.
Kullanımı tek satır, ama önce checkout gerekiyor; yerel bir action (./ ile başlayan) runner'ın workspace'inden okunur:
name: pipeline
on:
push:
branches: [main]
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
outputs:
sdk-version: ${{ steps.ci.outputs.sdk-version }}
steps:
- uses: actions/checkout@v7
- id: ci
uses: ./.github/actions/dotnet-ci
- run: dotnet publish api/src/Bookstore.Api -c Release -o out/api --no-build
- uses: actions/upload-artifact@v7
with:
name: api-publish
path: out/api
deploy-staging:
needs: ci
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets: inherit
report:
needs: [ci, deploy-staging]
runs-on: ubuntu-latest
steps:
- env:
SDK: ${{ needs.ci.outputs.sdk-version }}
FILES: ${{ needs.deploy-staging.outputs.deployed-files }}
run: echo "SDK $SDK, deployed $FILES files to staging"
Reusable Workflow: Job'ları Paketlemek
deploy-staging job'ının runs-on'u ve steps'i yok; onun yerine uses: ile başka bir workflow dosyasını çağırıyor. Çağrılan dosya on: workflow_call ile tetiklenen sıradan bir workflow:
name: reusable-deploy
on:
workflow_call:
inputs:
environment:
description: Target environment name
type: string
required: true
artifact-name:
type: string
default: api-publish
outputs:
deployed-files:
description: Number of files in the deployed package
value: ${{ jobs.deploy.outputs.files }}
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ vars.APP_URL }}
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
outputs:
files: ${{ steps.deploy.outputs.files }}
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact-name }}
path: out/api
- id: deploy
env:
APP_URL: ${{ vars.APP_URL }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
TARGET: ${{ inputs.environment }}
run: |
./deploy/deploy.sh "$TARGET" out/api
echo "files=$(find out/api -type f | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"
Output'un yolu üç basamaklı: step output'u $GITHUB_OUTPUT'a yazılır, job'ın outputs:'una çıkarılır, oradan da on.workflow_call.outputs'a. Çağıran taraf onu needs.deploy-staging.outputs.deployed-files olarak okur. Input'ların tipi var (string, number, boolean); required: true olan bir input'u vermeyi unutursan workflow başlamadan hata alırsın.
act, yerel reusable workflow'ları ve composite action'ları çalıştırabiliyor. 09'da anlattığım artifact sürümü değişikliğiyle koşunca çıktı:
[pipeline/ci] ✅ Success - Main Restore [3m52.585775875s]
[pipeline/ci] ✅ Success - Main Build [6.092938958s]
[pipeline/ci] | Test run summary: Passed!
[pipeline/ci] | total: 6
[pipeline/ci] | failed: 0
[pipeline/ci] | succeeded: 6
[pipeline/ci] ✅ Success - Main Unit tests [2.14828825s]
[pipeline/ci] ✅ Success - Main ./.github/actions/dotnet-ci [5m52.734271625s]
[pipeline/ci] 🏁 Job succeeded
[deploy-staging/reusable-deploy/deploy] | Deploying 13 files to staging at https://staging.bookstore.example
[deploy-staging/reusable-deploy/deploy] | Token length: 14
[deploy-staging/reusable-deploy/deploy] 🏁 Job succeeded
[pipeline/report ] | SDK 10.0.403, deployed 13 files to staging
[pipeline/report ] 🏁 Job succeeded
Composite action log'da tek bir step (./.github/actions/dotnet-ci) olarak görünüyor ama içindeki Restore, Build ve Unit tests adımları ayrı ayrı raporlanıyor. Restore'un neredeyse dört dakika sürmesi, act container'ının NuGet cache'i olmadan sıfırdan paket indirmesinden; 05. yazıdaki NuGet cache'i bu action'ın içine de konabilir. Reusable workflow'un job'ı deploy-staging/reusable-deploy/deploy adıyla, yani çağıran job, called workflow ve onun job'ı zinciriyle görünüyor. report job'ı iki ayrı yoldan gelen output'u birleştiriyor: SDK sürümü composite action'dan job output'una, dosya sayısı reusable workflow'un output'undan.
Secret'lar, Environment ve secrets: inherit
Reusable workflow'a secret'lar kendiliğinden geçmez. Ya tek tek verirsin (secrets: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}, called workflow'da da on.workflow_call.secrets altında tanımlı olmalı) ya da secrets: inherit ile çağıranın erişebildiği bütün secret'ları geçirirsin. Environment secret'ları için bir ayrıntı var: çağıran job environment: kullanamaz, bu yüzden environment'ı called workflow'daki job'a koyarsın. Dokümantasyona göre o job'ta aynı isimde bir environment secret'ı varsa o kazanır; ama çağıranın secret'ı yine de geçirmesi gerekiyor, geçirmezse değer sessizce boş string olur. required: true tanımlamak bu sessizliği hataya çevirir.
secrets: inherit kolay ama geniş: called workflow, çağıranın bütün repository ve organization secret'larını görür. Aynı organization'daki başka bir repository'nin reusable workflow'unu inherit ile çağırmak, o dosyayı değiştirebilen herkese bu secret'ları açmak demek. Inherit'i yalnızca aynı repository'deki workflow'lar için kullan, diğerlerinde secret'ları adıyla ver. Bir de zincir kuralı: secret'lar yalnızca doğrudan çağrılan workflow'a geçer; A, B'yi, B de C'yi çağırıyorsa B'nin onları C'ye ayrıca geçirmesi gerekir.
Permission'lar da benzer işler: zincir boyunca GITHUB_TOKEN permission'ları yalnızca aynı kalabilir veya daralabilir. Çağıran workflow'un seviyesindeki env: ise called workflow'a hiç geçmez; paylaşılması gereken her şey input olmalı.
Hangisi Ne Zaman: Karşılaştırma
| Konu | Composite action | Reusable workflow |
|---|---|---|
| Paketlediği birim | Step'ler | Job'lar |
| Çağrıldığı yer | Bir step'in uses:'u | Bir job'ın uses:'u |
Runner ve runs-on | Çağıran job'ınki | Kendi job'larında tanımlanır |
| Environment ve approval | Yok | Called job'da environment: |
| Secret'lar | Yalnızca input olarak | Adıyla veya secrets: inherit |
| Log'da görünüş | Tek step, içi açılabilir | Ayrı job'lar |
| Tipik kullanım | Setup, build, test adımları | Deploy, release, çok job'lı süreç |
Kabaca: "nasıl build edilir" composite action, "nasıl deploy edilir" reusable workflow. Deploy'un environment'a, approval'a ve concurrency'ye ihtiyacı var ve bunlar job seviyesinde yaşıyor.
Sınırlar, Sürümleme ve Paylaşmak
Kasım 2025'ten beri github.com'da reusable workflow zinciri on seviyeye kadar inebiliyor (çağıran workflow dahil) ve bir workflow dosyası, iç içe çağrılar dahil en fazla 50 benzersiz reusable workflow kullanabiliyor; önceki sınırlar dört seviye ve 20 workflow'du. Döngü yasak. Bu sınırlara yaklaşıyorsan muhtemelen sorun sınırda değil, tasarımda.
Temmuz 2026'da gelen $/ syntax'ı, aynı repository'deki bir action'a checkout yapmadan, çalışan commit üzerinden referans vermeyi sağlıyor: uses: $/.github/actions/dotnet-ci. @ref almaz, her zaman bulunduğu dosyanın repository'sine çözülür, runner 2.336.0 veya üstü ister ve GitHub Enterprise Server'da yok. Araçlar henüz tanımıyor; actionlint ve act ikisi de reddetti:
.github/workflows/selfref.yml:9:15: specifying action "$/.github/actions/dotnet-ci" in invalid format because ref is missing. available formats are "{owner}/{repo}@{ref}" or "{owner}/{repo}/{path}@{ref}" [action]
[self-ref/ci] Expected format {org}/{repo}[/path]@ref. Actual '$/.github/actions/dotnet-ci' Input string was not in a correct format
Bu yüzden örneklerde ./ kullanmaya devam ettim. Yine de bilmekte fayda var: 13. yazıda göreceğimiz workflow lockfile aracı, yerel ./ action'larını kilitleyemediği için onları $/'e çeviren bir seçenek sunuyor.
Action'ı başka repository'lerle paylaşacaksan kendi repository'sine taşı ve sürümle. GitHub'ın önerdiği düzen: her release için v1.2.0 gibi tam bir semver tag'i, üstüne kullanıcıların takip ettiği ve her uyumlu release'te ileri taşınan v1 major tag'i; kırıcı değişiklikte v2. Ekim 2025'te GA (General Availability) olan immutable release'ler açıksa release tag'leri kilitlenir; bu durumda v1'i GitHub release'i değil, düz bir Git tag'i olarak tutup ileri taşırsın. Organization genelinde ortak bir başlangıç noktası istiyorsan organization'ın .github repository'sindeki workflow-templates/ klasörüne bir .yml ve yanına aynı isimli bir .properties.json (name, description zorunlu) koyarsın; template'ler Actions sekmesindeki "New workflow" ekranında görünür. Template kopyalanır, referans edilmez; sonradan yaptığın değişiklik eski kopyalara yansımaz. Paylaşılan davranış için reusable workflow, paylaşılan başlangıç için template.
YAML Anchor'ları: Dosya İçi Tekrar
Eylül 2025'ten beri GitHub Actions workflow dosyalarında YAML anchor (&isim) ve alias (*isim) destekleniyor:
name: anchors
on: workflow_dispatch
permissions:
contents: read
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
env: &deploy-env
APP_URL: ${{ vars.APP_URL }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
steps: &deploy-steps
- uses: actions/checkout@v7
- name: Deploy
run: echo "Deploying to $APP_URL as job $GITHUB_JOB"
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
env: *deploy-env
steps: *deploy-steps
actionlint temiz geçti, act da iki job'ı aynı step'lerle koştu:
[anchors/deploy-staging] | Deploying to https://staging.bookstore.example as job deploy-staging
[anchors/deploy-staging] 🏁 Job succeeded
[anchors/deploy-production] | Deploying to https://staging.bookstore.example as job deploy-production
[anchors/deploy-production] 🏁 Job succeeded
Production'ın da staging adresini yazması act'in tek variable havuzundan; GitHub'da vars.APP_URL her job'da kendi environment'ından çözülür. Anchor, expression'ı değil metni kopyalar; değerlendirme her job'da ayrı yapılır.
Tahminin ikinci sorusu: merge key (<<) desteklenmiyor. Aynı env'i <<: *deploy-env ile birleştirip APP_URL'i ezmeye çalıştığımda actionlint açıkça söyledi, act de şemaya uymadığı için başlatmadı:
.github/workflows/merge.yml:17:7: GitHub Actions does not support YAML merge key "<<" [syntax-check]
Error: workflow is not valid. 'merge.yml': Line: 15 Column 5: Failed to match job-factory: Line: 8 Column 10: Expected a scalar got mapping
Yani anchor'la ancak bloğun tamamını tekrar kullanabilirsin; "aynısı, şu farkla" için reusable workflow'un input'ları veya environment variable'ları gerekir. Anchor'ın kapsamı da tek dosya; dosyalar arası paylaşım için diğer iki araç var.
Üretimde Dikkat
Paylaşılan action'ları ve reusable workflow'ları tag ile değil commit SHA'sı (Secure Hash Algorithm) ile çağır, özellikle başka repository'den geliyorlarsa. Composite action'ın içindeki uses: satırları da çağıranın tedarik zincirinin parçası; bunu 13. yazıda ayrıntılı konuşacağız.
Reusable workflow'un input'larını sözleşme gibi yönet. Input adını değiştirmek, onu çağıran her workflow'u kırar. Yeni input eklerken default ver, eskisini kaldırmadan önce bir major sürüm bekle.
Log'da nerede olduğunu bilmek için adlandır. Composite action'ın step'lerine name: ver; reusable workflow'un job adları çağıranın job adıyla birleşerek görünür (deploy-staging / deploy). İsimsiz step'ler hata anında "hangi adım düştü" sorusunu zorlaştırır.
Debug edilebilirliği ölç. Bir hata mesajını kaynağına kadar takip etmek üç repository ve dört dosya gerektiriyorsa soyutlama fazla derin. Seviye sayısını ikide tut.
Sık Düşülen Tuzaklar
Composite action'da shell:'i unutmak. Workflow'da gerekmeyen alan burada zorunlu.
Composite action'da secrets context'ini kullanmak. Secret input olarak gelmeli.
Reusable workflow çağıran job'a steps:, runs-on: veya environment: eklemek. Çağıran job yalnızca name, uses, with, secrets, strategy, needs, if, concurrency, permissions ve yeni cache-mode alanlarını alabilir.
Called workflow'da secret'ı required: true yapmamak. Unutulan bir secret hata vermez, boş gelir; deploy da token'sız denenir.
Workflow template'ini "merkezi configuration" sanmak. Template kopyalanır; güncellemek istediğin davranışı reusable workflow'a koy.
Anchor'la "neredeyse aynı" bloklar kurmaya çalışmak. Merge key yok; ya bloğun tamamı ya hiç.
Küçük Bir Deneme
★☆☆ dotnet-ci composite action'ına run-tests adında boolean bir input ekle ve test step'ini if: inputs.run-tests == 'true' ile koşullu yap. Input'ların neden string karşılaştırıldığını act ile doğrula.
★★☆ pipeline.yml'a, aynı reusable workflow'u environment: production ile çağıran ve yalnızca v* tag'lerinde koşan bir deploy-production job'ı ekle. secrets: inherit yerine secret'ı adıyla geçir ve called workflow'da required: true tanımla.
★★★ Deploy job'ını matrix ile çağır: strategy.matrix.target: [staging, qa] ve with.environment: ${{ matrix.target }}. Called workflow'un deployed-files output'u hangi matrix koşusundan geliyor? Dokümantasyondaki kuralla act çıktısını karşılaştır.
Tekrarı azalttık ama bu yazıda yazdığımız her uses: satırı, kodu bizim olmayan bir repository'ye güveniyor. Bir sonraki yazıda bu güvenin nasıl istismar edildiğini (tj-actions vakası), SHA pinning'i, workflow lockfile'larını, pull_request_target'ın tuzaklarını ve script injection'dan korunmayı ele alıyoruz.