Azure Pipelines Template'leri: Step, Job, Stage ve extends Template ile Ortak Pipeline Kurmak
Azure Pipelines template'leri step, job, stage ve variable seviyesinde YAML'ı tekrar kullanmayı sağlar; extends template ise pipeline'ın iskeletini merkeze alır ve required template check ile bağlayıcı olur. Bookstore için .NET ve Angular job template'lerini ayrı bir pipeline-templates repository'sine taşıyıp tag'e pin'liyor, each ve koşullu eklemeyle inline script'leri engelleyen, audit job'ını zorunlu kılan bir iskelet kuruyoruz.
Sürüm notu: Bu yazıdaki .NET 10 (SDK 10.0.404) ve Angular 22 (Node.js 24) adımları yerelde denenmiştir; bütün pipeline ve template dosyaları yq 4.54 ile ayrıştırılarak denetlenmiştir. Template'lerin açılması, repository resource'ları ve check'ler gibi Azure Pipelines davranışları için Microsoft Learn'deki resmî belgelerden (Kasım 2026 sürümleri) yararlanılmıştır.
Önce Tahmin Et
Bookstore'un platform ekibi, sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) bütün pipeline'larının kullandığı ortak template'leri pipeline-templates adında ayrı bir repository'de tutuyor. Uygulama pipeline'ları bu repository'yi şöyle bağlamış:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
jobs:
- template: jobs/dotnet-ci.yml@templates
parameters:
solution: Bookstore.slnx
Salı sabahı 09:40'ta Hakan'ın ekibinden biri pipeline-templates'in main branch'ine bir değişiklik push'luyor: dotnet-ci.yml'daki solution parameter'ının adı solutionPath oluyor. O anda üç pipeline çalışıyor, öğleye kadar da on dört pipeline daha başlayacak. Hangileri kırılır?
Cevap: o anda çalışan üç run etkilenmez, öğleye kadar başlayan on dördünün hepsi kırılır. Belgelere göre repository resource'ları run başlarken bir kez çözülüyor ve run boyunca aynı içerik kullanılıyor; çalışan run'lar eski template'le devam ediyor. ref verilmemiş bir template repository'si için varsayılan refs/heads/main; yani her yeni run o anki main'i alıyor, kendi dosyasında olmayan solution parameter'ını veren her pipeline da daha hiçbir job başlamadan, dosya işlenirken hata veriyor. Hangi branch'ten çalıştığının da önemi yok; feature branch'teki run'lar da aynı main'i görüyor. Bu yazıda template'leri ortak bir yere taşırken bu riski sürüm sabitleyerek nasıl kapatacağımızı da göreceğiz.
Template Türleri: Step, Job, Stage ve Variable
Azure Pipelines'ta template, başka bir YAML (girintiyle yapı kuran metin formatı) dosyasına yerleştirilen bir YAML parçası. Dört seviyede yazılabiliyor: step listesi veren step template'i, bir ya da birkaç job veren job template'i, stage veren stage template'i ve değişken veren variable template'i. Her biri çağrıldığı yere aynı seviyeden yerleşiyor: steps: altında - template: steps/dotnet-setup.yml, jobs: altında - template: jobs/dotnet-ci.yml gibi. Bu "include" kullanımı; bir de bütün pipeline'ın iskeletini template'e bırakan extends var, ona aşağıda geleceğiz.
Template yolu iki türlü yazılabiliyor: / ile başlıyorsa repository köküne göre, başlamıyorsa template'i çağıran dosyanın bulunduğu klasöre göre. Başka bir repository'deki template'e dosya.yml@takma-ad biçiminde erişiliyor. Template'in içinden çağrılan başka bir template ise o template'in kendi repository'sinde aranıyor; bu sayede ortak repository kendi içinde göreli yollarla düzenli kalabiliyor. Belgeler patlamayı önlemek için üç sınır koyuyor: bir pipeline'a doğrudan ya da dolaylı en fazla 100 ayrı YAML dosyası girebiliyor, iç içe template derinliği en fazla 100 ve dosya işlenirken en fazla 20 MB bellek kullanılabiliyor.
En basit template, birkaç step'i bir araya getiren step template'i. Bookstore'da her .NET job'ının başındaki iki adımı ayırdık:
# global.json'daki SDK'yı kurar ve solution'ı restore eder.
parameters:
- name: solution
type: string
steps:
- task: UseDotNet@2
displayName: Install .NET SDK from global.json
inputs:
packageType: sdk
useGlobalJson: true
- script: dotnet restore ${{ parameters.solution }}
displayName: Restore
Variable template'i ise ortak değişkenleri taşıyor. Secret olmayan değişkenler step'lere environment variable olarak da gittiği için, .NET CLI'ın açılış mesajlarını ve telemetrisini kapatan ayarları burada bir kez tanımlamak bütün job'lara yetiyor:
# Her Bookstore pipeline'ının aldığı değişkenler. Secret olmayanlar step'lere environment variable olarak da gider.
variables:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
Parameter Tipleri, each ve Koşullu Ekleme
Template'in parameter'ları ikinci yazıdaki runtime parameter'larla aynı tip sistemini kullanıyor: string, number, boolean, object ve YAML parçası taşıyan step, stepList, job, jobList, stage, stageList. Template'e kod parçası vermek istediğinde stepList ve jobList en kullanışlıları: çağıran taraf bir step listesi veriyor, template onu istediği yere yerleştiriyor. Yanlış tipte bir değer verilirse pipeline daha başlamadan hata veriyor; object tipi ise her şeyi kabul ettiği için bu korumayı vermiyor.
Template'in içinde üç yönerge var. ${{ if }}, ${{ elseif }} ve ${{ else }} bir parçayı koşula göre ekliyor ya da eklemiyor; ${{ each }} bir liste ya da mapping üzerinde dönüp her eleman için bir parça üretiyor. Hepsi template expression ve hepsi dosya işlenirken çalışıyor; çalışma sırasında üretilen bir değeri (bir job'ın output'u, logging command'la değiştirilmiş bir değişken) göremiyor. dotnet-ci.yml içinde iki kullanım var: çağıranın verdiği ek step'leri build ile test arasına yerleştiren bir döngü ve integration testlerini parameter'a göre ekleyen bir koşul.
- ${{ each step in parameters.extraSteps }}:
- ${{ step }}
- ${{ if eq(parameters.runIntegrationTests, true) }}:
- script: bash ${{ parameters.testDbScript }}
displayName: Start SQL Server for integration tests
- script: dotnet test --solution ${{ parameters.solution }} --no-build -c ${{ parameters.buildConfiguration }}
displayName: Test
env:
ConnectionStrings__BookstoreDb: $(TestDbConnection)
Dikkat edersen aynı dosyada iki yazım yan yana: ${{ parameters.solution }} template açılırken metne dönüşüyor, $(TestDbConnection) ise metin olarak kalıp agent'ta çözülüyor. Template'in içinde runtime değerine ihtiyacın varsa yazım hâlâ $( ) veya $[ ].
Bookstore İçin Job Template'leri
İkinci yazıdaki Version ve Api job'larını template'e taşıdık ve yanlarına Angular için bir job ekledik. Angular job'ı şöyle:
# Bir Angular uygulamasını kurar, test eder ve derler.
parameters:
- name: workingDirectory
type: string
- name: nodeVersion
type: string
default: 24.x
jobs:
- job: Web
displayName: Build and test web
dependsOn: Version
steps:
- checkout: self
- task: UseNode@1
displayName: Install Node.js ${{ parameters.nodeVersion }}
inputs:
version: ${{ parameters.nodeVersion }}
- script: npm ci
displayName: npm ci
workingDirectory: ${{ parameters.workingDirectory }}
- script: npm test -- --watch=false
displayName: Unit tests
workingDirectory: ${{ parameters.workingDirectory }}
- script: npm run build
displayName: Build
workingDirectory: ${{ parameters.workingDirectory }}
Node.js için UseNode@1 kullandık. Eski örneklerde sık görülen NodeTool@0, task belgesine göre artık deprecated ve yerine bu task öneriliyor. Ubuntu 24.04 image'ında varsayılan Node.js 22; Angular 22 onunla da çalışıyor, ama seri Node 24 ile ilerlediği için sürümü açıkça veriyoruz. Aynı üç komutun yerelde, aynı repository'de verdiği çıktının önemli kısmı (yolu kısalttım):
$ npm test -- --watch=false
> bookstore-web@0.0.0 test
> ng test --watch=false
RUN v5.1.0 …/web/bookstore-web
Test Files 1 passed (1)
Tests 1 passed (1)
$ npm run build
> bookstore-web@0.0.0 build
> ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main-BDBHRKZT.js | main | 264.83 kB | 73.11 kB
styles-S5NLN6H7.css | styles | 930 bytes | 930 bytes
| Initial total | 265.76 kB | 74.05 kB
Output location: …/web/bookstore-web/dist/bookstore-web
Template'ler ayrı repository'deyse uygulama pipeline'ı artık neredeyse yalnızca çağrılardan oluşuyor. Ara bir adım olarak, extends kullanmadan job template'lerini doğrudan çağırmak şöyle görünür:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
ref: refs/tags/v1.0.0
pool:
vmImage: ubuntu-24.04
jobs:
- template: jobs/version.yml@templates
- template: jobs/dotnet-ci.yml@templates
parameters:
solution: Bookstore.slnx
- template: jobs/angular-ci.yml@templates
parameters:
workingDirectory: web/bookstore-web
Bu biçimde .NET ve Angular job'ları, parameter'larıyla birlikte ikişer satırlık çağrılara indi. Ama bir eksiği var: uygulama ekibi bu dosyayı istediği gibi değiştirebilir, bir template'i çağırmayı bırakabilir ya da araya istediği job'ı koyabilir. Bunu kapatmak için extends var.
Ayrı Bir pipeline-templates Repository'si ve Tag'e Pin
Template'leri her uygulama repository'sine kopyalamak bir süre sonra on dört farklı dotnet-ci.yml demek. Ortak bir repository bunu çözüyor ama kendi kurallarını getiriyor. Repository, uygulama pipeline'ında resources.repositories altında bir takma adla tanımlanıyor. Azure Repos için type: git ve name: <project>/<repository>, GitHub için type: github ve name: <organization>/<repository> yazılıyor. Template repository'si farklı bir Azure DevOps organization'ındaysa erişim için bir service connection tanımlayıp endpoint: ile vermek gerekiyor.
ref anahtarı hangi sürümün kullanılacağını söylüyor: refs/heads/<branch>, refs/tags/<tag> ya da 40 karakterlik bir commit SHA'sı (commit içeriğinden hesaplanan özet değer). Bizim tercihimiz tag: platform ekibi template'leri bir library gibi sürümlüyor (v1.0.0, v1.1.0), uygulama ekipleri yeni sürüme pull request'le geçiyor. Kıran bir değişiklik büyük sürüm numarasını artırıyor ve eski tag yerinde kaldığı için kimse istemeden etkilenmiyor. Tag'lerin taşınabildiğini unutma: Git bir tag'in başka commit'e yeniden yazılmasını engellemiyor. Bunu önlemek için template repository'sinde tag'leri yalnızca platform ekibinin oluşturmasına izin veren repository izinleri ya da gerçekten değişmez bir referans için commit SHA'sı kullanılabiliyor.
Bir kural daha var ve çok kişiyi şaşırtıyor: template repository'sindeki script dosyaları pipeline'da çalıştırılamıyor. Belgeler bunu açıkça yazıyor: template'ler tamamen açıldıktan sonra pipeline, sanki tamamen kaynak repository'de tanımlanmış gibi çalışıyor ve agent'a yalnızca self checkout ediliyor. jobs/version.yml'ın bash scripts/set-version.sh satırı bu yüzden Bookstore repository'sindeki script'i çalıştırıyor; script yolunu parameter yaptık ki başka bir uygulama kendi yolunu verebilsin. Script'i gerçekten template repository'sinde tutmak istersen job'a ikinci bir checkout: templates step'i eklemen gerekiyor; bu durumda iki repository ayrı alt klasörlere iniyor ve yollar değişiyor.
extends Template ile Zorunlu İskelet
Include template'lerinde kontrol çağıranda: dosyanın yapısını uygulama pipeline'ı belirliyor. extends'te durum tersine dönüyor: pipeline'ın iskeletini template belirliyor, uygulama pipeline'ı yalnızca template'in kabul ettiği parameter'ları veriyor. Bookstore'un platform ekibi için yazdığımız iskelet şöyle:
# Bookstore CI pipeline'ları için zorunlu iskelet ("extends" ile kullanılır).
# Uygulama pipeline'ları değerleri seçer; yapı, pool ve audit job'ı sabit kalır.
parameters:
- name: solution
type: string
- name: webDirectory
type: string
default: ''
- name: buildConfiguration
type: string
default: Release
values:
- Release
- Debug
- name: runIntegrationTests
type: boolean
default: true
- name: extraApiSteps
type: stepList
default: []
stages:
- stage: Build
displayName: Build and test
pool:
vmImage: ubuntu-24.04
variables:
- template: ../variables/common.yml
jobs:
- template: ../jobs/version.yml
- template: ../jobs/dotnet-ci.yml
parameters:
solution: ${{ parameters.solution }}
buildConfiguration: ${{ parameters.buildConfiguration }}
runIntegrationTests: ${{ parameters.runIntegrationTests }}
extraSteps:
# Burada yalnızca task kabul edilir; inline script'ler hata veren bir step'le değiştirilir.
- ${{ each step in parameters.extraApiSteps }}:
- ${{ if or(startsWith(step.task, 'Bash'), startsWith(step.task, 'CmdLine'), startsWith(step.task, 'PowerShell')) }}:
- script: |
echo "##vso[task.logissue type=error]Inline scripts are not allowed in extraApiSteps."
exit 1
displayName: Blocked by template
- ${{ else }}:
- ${{ step }}
- ${{ if ne(parameters.webDirectory, '') }}:
- template: ../jobs/angular-ci.yml
parameters:
workingDirectory: ${{ parameters.webDirectory }}
- job: Audit
displayName: Dependency audit (required)
dependsOn: []
steps:
- checkout: self
- template: ../steps/dotnet-setup.yml
parameters:
solution: ${{ parameters.solution }}
- script: |
dotnet list ${{ parameters.solution }} package --vulnerable --include-transitive | tee audit.txt
if grep -q "has the following vulnerable packages" audit.txt; then
echo "##vso[task.logissue type=error]Vulnerable packages found."
exit 1
fi
displayName: Fail on vulnerable packages
Bu iskelet üç şeyi sabitliyor. Pool'u: uygulama pipeline'ı image seçemiyor. Audit job'ını: dependsOn: [] ile diğer job'lara bağlı olmadan, her run'da çalışıyor ve bilinen güvenlik açığı olan bir NuGet paketi varsa run'ı kırıyor. Gerçek dependency ve code scanning DevSecOps serisinin konusu; buradaki amaç, platform ekibinin istediği bir kontrolü uygulama ekibinin kaldıramayacağı bir yere koymak. Üçüncüsü, uygulamanın ekleyebileceği step'ler: extraApiSteps bir stepList, ve each döngüsü her step'e bakıp inline script olanları hata veren bir step'le değiştiriyor. Belgelerdeki örneğe göre script:, bash: ve powershell: kısayolları bu noktada task biçimine (CmdLine@2, Bash@3, PowerShell@2) dönüşmüş olarak görülüyor; kontrolü bu yüzden task adının başına göre yapıyoruz.
Audit step'inin yerelde gördüğü çıktı şimdilik temiz (yolu kısalttım):
$ dotnet list Bookstore.slnx package --vulnerable --include-transitive
Determining projects to restore...
All projects are up-to-date for restore.
The following sources were used:
https://api.nuget.org/v3/index.json
The given project `Bookstore.Api` has no vulnerable packages given the current sources.
The given project `Bookstore.Api.Tests` has no vulnerable packages given the current sources.
Bookstore'un azure-pipelines.yml'ı artık trigger'lar, template repository'si ve tek bir extends bloğundan ibaret:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
ref: refs/tags/v1.0.0
extends:
template: pipelines/bookstore-ci.yml@templates
parameters:
solution: Bookstore.slnx
webDirectory: web/bookstore-web
buildConfiguration: ${{ parameters.buildConfiguration }}
runIntegrationTests: ${{ parameters.runIntegrationTests }}
extraApiSteps:
- task: DotNetCoreCLI@2
displayName: Check formatting
inputs:
command: custom
custom: format
arguments: Bookstore.slnx --verify-no-changes --no-restore
Uygulama ekibi formatting kontrolünü bir task olarak ekledi; iskelet bunu kabul ediyor. Aynı kontrol, bir dosyada fazladan iki boşluk olduğunda yerelde şöyle kırılıyor:
$ dotnet format Bookstore.slnx --verify-no-changes --no-restore
src/Bookstore.Api/Catalog/Category.cs(5,5): error WHITESPACE: Fix whitespace formatting. Delete 2 characters. [src/Bookstore.Api/Bookstore.Api.csproj]
Komut 2 exit code'uyla çıkıyor, task kırmızıya dönüyor ve test step'leri varsayılan succeeded() koşulu yüzünden atlanıyor. Trigger'ların ve parameters bloğunun uygulama dosyasında kaldığına dikkat et: zamanlamaların ana dosyada olması zorunlu; runtime parameter'ları da ana dosyada tanımlayıp değerlerini extends'e aktardık. Path filter'dan web'i çıkardık, çünkü pipeline artık Angular'ı da derliyor.
Required Template Check ile Kuralı Bağlayıcı Yapmak
extends tek başına bir öneri: uygulama ekibi dosyadan extends'i silip kendi job'larını yazabilir. Kuralı bağlayıcı yapan şey, korunan bir resource'a eklenen "Required template" check'i. Belgelere göre check'ler environment'lara, service connection'lara, repository'lere, variable group'lara, secure file'lara ve agent pool'lara eklenebiliyor ve onları pipeline yazarı değil resource'un sahibi yönetiyor. Required template check'inde repository türü, repository, ref ve template'in yolu veriliyor; o resource'u kullanan bir stage, pipeline belirtilen template'ten extend etmiyorsa başlamadan başarısız oluyor. Bu check "static check" grubunda ve onaylardan önce değerlendiriliyor.
Önemli bir sınır var: check yalnızca o resource'u kullanan stage'ler için devreye giriyor. Bizim CI pipeline'ımız henüz korunan bir resource kullanmıyor; dolayısıyla bugün extends'i silen bir CI pipeline'ını durduracak bir şey yok. Kuralın asıl değeri production'a giden yolda ortaya çıkıyor: altıncı yazıdaki production environment'ına ve yedinci yazıdaki Azure service connection'ına required template check eklendiğinde, onaylı iskeletten geçmeyen hiçbir pipeline o kaynaklara dokunamıyor. Belgeler aynı resource için birden fazla required template tanımlanabileceğini ve ref olarak bir tag verilebileceğini söylüyor; bu da template sürümünü yükseltirken eski ve yeni tag'i bir süre birlikte kabul etmeyi mümkün kılıyor.
Yerelde Neyi Denetleyebiliriz
Template'lerin açılmış hâlini görmek için Azure Pipelines'ın kendisi gerekiyor: YAML editöründeki "Download full YAML" seçeneği pipeline'ı çalıştırmadan, bütün template'leri açılmış tek bir dosya olarak indiriyor. Hesap gerektirmeyen tarafta yapabildiğimiz, her dosyanın tek başına geçerli YAML olduğunu doğrulamak:
$ for f in azure-pipelines.yml pipeline-templates/*/*.yml; do yq 'true' "$f" > /dev/null && echo "OK $f"; done
OK azure-pipelines.yml
OK pipeline-templates/jobs/angular-ci.yml
OK pipeline-templates/jobs/dotnet-ci.yml
OK pipeline-templates/jobs/version.yml
OK pipeline-templates/pipelines/bookstore-ci.yml
OK pipeline-templates/steps/dotnet-setup.yml
OK pipeline-templates/variables/common.yml
Bu döngü YAML yapısını yakalıyor; template expression'ların doğru açılıp açılmadığını, parameter adlarının eşleşip eşleşmediğini söylemiyor. Template repository'si için asıl güvence, değişikliği tag'lemeden önce o repository'deki bir test pipeline'ında yeni sürümü örnek bir uygulamayla çalıştırmak.
Üretimde Dikkat
Template repository'sine bir ürün gibi davran. Changelog, sürüm numarası ve hangi uygulamanın hangi tag'i kullandığının listesi olmadan, "template'te güvenlik düzeltmesi yaptık, herkes geçsin" demek haftalar sürüyor. Uygulama repository'lerindeki ref satırları aranabilir olduğu için hangi pipeline'ın eski sürümde kaldığını bulmak kolay; bunu düzenli olarak yapan küçük bir rapor işini görüyor.
Parameter'ları dar tut. Her yeni parameter, template'in desteklemesi gereken bir kombinasyon daha demek. object tipi esnek görünüyor ama tip kontrolünü ortadan kaldırıyor; mümkünse string ve values: ya da stepList kullan. İskelete "her şeyi değiştirilebilir" bir kaçış kapısı açmak, extends'in getirdiği güvenceyi boşa çıkarıyor.
Ortak template'lerin performans etkisini de ölç. Audit job'ı her run'da ayrı bir VM açıyor ve restore yapıyor; tek parallel job'lı bir organization'da bu doğrudan run süresine ekleniyor. Kontrolün değeri bu bedele değiyor mu, yoksa API job'ının içine bir step olarak mı girmeli, bu karar platform ekibinin.
Sık Düşülen Tuzaklar
main'e pin'lemek ya da hiç ref vermemek. Giriş bölümündeki senaryo: varsayılan refs/heads/main, ve template repository'sine yapılan her push bütün pipeline'ların bir sonraki run'ını değiştiriyor.
Template expression'da runtime değerini beklemek. ${{ if eq(variables['appVersion'], '') }} gibi bir koşul hep doğru çıkıyor, çünkü appVersion dosya işlendikten sonra üretiliyor. Template içinde karar vermek için yalnızca parameter'lar, dosyada sabit duran değişkenler ve belgelerde template'te kullanılabilir olarak işaretlenmiş önceden tanımlı değişkenler var.
Template repository'sindeki script'i çağırmak. bash scripts/x.sh satırı her zaman agent'a checkout edilen repository'de aranıyor; template repository'si checkout edilmediği sürece dosya bulunamıyor.
Job adlarının çakışması. Aynı job template'ini aynı stage'de iki kez çağırırsan iki job aynı adı taşıyor ve pipeline başlamadan hata veriyor. Birden fazla kez kullanılacak job template'lerinde job adını parameter yapmak gerekiyor.
Zamanlamayı template'e koymak. schedules yalnızca ana pipeline dosyasında tanımlanabiliyor; template'teki bir zamanlama dikkate alınmıyor.
Küçük Bir Deneme
★☆☆ jobs/angular-ci.yml'a bir runLint boolean parameter'ı ekle ve true olduğunda npm run lint step'ini koşullu ekleme ile yerleştir.
★★☆ extraApiSteps'e bir - script: echo hello step'i ekleyip açılmış YAML'ın nasıl görüneceğini kâğıt üzerinde yaz. Ardından iskelete, yalnızca DotNetCoreCLI@2 ve PublishTestResults@2 task'larına izin veren bir "izin listesi" kuralı ekle.
★★★ Template repository'si için kendi pipeline'ını tasarla: her pull request'te iskeleti örnek bir uygulamayla çalıştırsın, main'e merge sonrasında da bir sonraki tag'i önersin. Uygulama repository'lerinde eski tag'de kalanları listeleyen bir script yaz.
İskeletimiz hazır, ama içindeki job'lar hâlâ her run'da paketleri sıfırdan indiriyor, test sonuçlarını yalnızca log'da bırakıyor ve hiçbir çıktı üretmiyor. Bir sonraki yazıda .NET ve Angular job'larına cache, test sonucu ve coverage yayınlamayı ekleyip iki job'ın çıktısını tek bir release paketinde birleştireceğiz.